[
  {
    "path": ".dockerignore",
    "content": "Dockerfile\nresults"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ntitle: \"[Bug]: \"\nlabels: bug\ndescription: Report broken or incorrect behaviour\nbody:\n  - type: markdown\n    attributes:\n      value: >\n        Thanks for taking the time to fill out a bug.\n        Please note that this form is for bugs only!\n  - type: textarea\n    id: what-happened\n    attributes:\n      label: Describe the bug\n      description: A clear and concise description of what the bug is.\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Reproduction Steps\n      description: >\n         What you did to make it happen.\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Expected behavior\n      description: >\n         A clear and concise description of what you expected to happen.\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Screenshots\n      description: >\n         If applicable, add screenshots to help explain your problem.\n    validations:\n      required: false\n  - type: textarea\n    attributes:\n      label: System Information\n      description: please fill your system informations\n      value: >\n        Operating System : [e.g. Windows 11]\n\n        Python version : [e.g. Python 3.6]\n\n        App version / Branch : [e.g. latest, V2.0, master, develop]\n    validations:\n      required: true\n  - type: checkboxes\n    attributes:\n      label: Checklist\n      description: >\n        Let's make sure you've properly done due diligence when reporting this issue!\n      options:\n        - label: I have searched the open issues for duplicates.\n          required: true\n        - label: I have shown the entire traceback, if possible.\n          required: true\n  - type: textarea\n    attributes:\n      label: Additional Context\n      description: Add any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Ask a question\n    about: Join our discord server to ask questions and discuss with maintainers and contributors.\n    url: https://discord.gg/swqtb7AsNQ"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Suggest an idea for this project\nlabels: enhancement\ntitle: \"[Feature]: \"\nbody:\n  - type: input\n    attributes:\n      label: Summary\n      description: >\n        A short summary of what your feature request is.\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Is your feature request related to a problem?\n      description: >\n        if yes, what becomes easier or possible when this feature is implemented?\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Describe the solution you'd like\n      description: >\n        A clear and concise description of what you want to happen.\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Describe alternatives you've considered\n      description: >\n        A clear and concise description of any alternative solutions or features you've considered.\n    validations:\n      required: false\n\n\n  - type: textarea\n    attributes:\n      label: Additional Context\n      description: Add any other context or screenshots about the feature request here."
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "# Description\n\n<!-- Please include a summary of the change and which issue is fixed. Please also include relevant context. List any dependencies that are required for this change. -->\n\n# Issue Fixes\n\n<!-- Fixes #(issue) if relevant-->\n\nNone\n\n# Checklist:\n\n- [ ] I am pushing changes to the **develop** branch\n- [ ] I am using the recommended development environment\n- [ ] I have performed a self-review of my own code\n- [ ] I have commented my code, particularly in hard-to-understand areas\n- [ ] I have formatted and linted my code using python-black and pylint\n- [ ] I have cleaned up unnecessary files\n- [ ] My changes generate no new warnings\n- [ ] My changes follow the existing code-style\n- [ ] My changes are relevant to the project\n\n# Any other information (e.g how to test the changes)\n\nNone\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"pip\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"daily\"\n    target-branch: \"develop\"\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "\n# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches: [ \"master\", \"develop\" ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ \"master\", \"develop\" ]\n  schedule:\n    - cron: '16 14 * * 3'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'python' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]\n        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v4\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v2\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n\n        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs\n        # queries: security-extended,security-and-quality\n\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v2\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun\n\n    #   If the Autobuild fails above, remove it and uncomment the following three lines.\n    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.\n\n    # - run: |\n    #   echo \"Run, Build Application using script\"\n    #   ./location_of_script_within_repo/buildscript.sh\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v2\n"
  },
  {
    "path": ".github/workflows/fmt.yml",
    "content": "# GitHub Action that uses Black to reformat the Python code in an incoming pull request.\n# If all Python code in the pull request is compliant with Black then this Action does nothing.\n# Othewrwise, Black is run and its changes are committed back to the incoming pull request.\n# https://github.com/cclauss/autoblack\n\nname: fmt\non:\n  push:\n    branches: [\"develop\"]\njobs:\n  format:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up Python 3.10\n        uses: actions/setup-python@v5\n        with:\n          python-version: 3.10.14\n      - name: Install Black & isort\n        run: pip install black isort\n      - name: Run black check\n        run: black --check . --line-length 101\n      - name: Run isort check\n        run: isort . --check-only --diff --profile black\n      - name: If needed, commit changes to the pull request\n        if: failure()\n        run: |\n          black . --line-length 101\n          isort . --profile black\n          git config --global user.name github-actions\n          git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com\n          git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY\n          git checkout $GITHUB_HEAD_REF\n          git commit -am \"fixup: Format Python code with Black\"\n          git push origin HEAD:develop\n\n          \n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint\n\non: [pull_request]\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: psf/black@stable\n        with:\n          options: \"--line-length 101\"\n      - uses: isort/isort-action@v1\n        with:\n          configuration: \"--check-only --diff --profile black\"\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: 'Stale issue handler'\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '0 0 * * *'\n\njobs:\n\n  stale:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n      pull-requests: write\n    steps:\n      - uses: actions/stale@v9\n        id: stale-issue\n        name: stale-issue\n        with:\n          # general settings\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n          stale-issue-message: 'This issue is stale because it has been open 7 days with no activity. Remove stale label or comment, or this will be closed in 10 days.'\n          close-issue-message: 'Issue closed due to being stale. Please reopen if issue persists in latest version.'\n          days-before-stale: 7\n          days-before-close: 15\n          stale-issue-label: 'stale'\n          close-issue-label: 'outdated'\n          exempt-issue-labels: 'enhancement,keep,blocked'\n          exempt-all-issue-milestones: true\n          operations-per-run: 300\n          remove-stale-when-updated: true\n          ascending: true\n          #debug-only: true\n\n          stale-pr-message: 'This pull request is stale as it has been open for 7 days with no activity. Remove stale label or comment, or this will be closed in 10 days.'\n          close-pr-message: 'Pull request closed due to being stale.'\n          close-pr-label: 'outdated'\n          stale-pr-label: 'stale'\n          exempt-pr-labels: 'keep,blocked,before next release,after next release'\n  \n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider\n# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839\n\n# User-specific stuff\n.idea/**/workspace.xml\n.idea/**/tasks.xml\n.idea/**/usage.statistics.xml\n.idea/**/dictionaries\n.idea/**/shelf\n\n# AWS User-specific\n.idea/**/aws.xml\n\n# Generated files\n.idea/**/contentModel.xml\n\n# Sensitive or high-churn files\n.idea/**/dataSources/\n.idea/**/dataSources.ids\n.idea/**/dataSources.local.xml\n.idea/**/sqlDataSources.xml\n.idea/**/dynamic.xml\n.idea/**/uiDesigner.xml\n.idea/**/dbnavigator.xml\n\n# Gradle\n.idea/**/gradle.xml\n.idea/**/libraries\n\n# Gradle and Maven with auto-import\n# When using Gradle or Maven with auto-import, you should exclude module files,\n# since they will be recreated, and may cause churn.  Uncomment if using\n# auto-import.\n# .idea/artifacts\n# .idea/compiler.xml\n# .idea/jarRepositories.xml\n# .idea/modules.xml\n# .idea/*.iml\n# .idea/modules\n# *.iml\n# *.ipr\n\n# CMake\ncmake-build-*/\n\n# Mongo Explorer plugin\n.idea/**/mongoSettings.xml\n\n# File-based project format\n*.iws\n\n# IntelliJ\nout/\n\n# mpeltonen/sbt-idea plugin\n.idea_modules/\n\n# JIRA plugin\natlassian-ide-plugin.xml\n\n# Cursive Clojure plugin\n.idea/replstate.xml\n\n# SonarLint plugin\n.idea/sonarlint/\n\n# Crashlytics plugin (for Android Studio and IntelliJ)\ncom_crashlytics_export_strings.xml\ncrashlytics.properties\ncrashlytics-build.properties\nfabric.properties\n\n# Editor-based Rest Client\n.idea/httpRequests\n\n# Android studio 3.1+ serialized cache file\n.idea/caches/build_file_checksums.ser\n\nassets/temp\nassets/backgrounds\n/.vscode\nout\n.DS_Store\n.setup-done-before\nresults/*\nreddit-bot-351418-5560ebc49cac.json\n/.idea\n*.pyc\nvideo_creation/data/videos.json\nvideo_creation/data/envvars.txt\n\nconfig.toml\n*.exe\n"
  },
  {
    "path": ".pylintrc",
    "content": "[MAIN]\n\n# Analyse import fallback blocks. This can be used to support both Python 2 and\n# 3 compatible code, which means that the block might have code that exists\n# only in one or another interpreter, leading to false positives when analysed.\nanalyse-fallback-blocks=no\n\n# Load and enable all available extensions. Use --list-extensions to see a list\n# all available extensions.\n#enable-all-extensions=\n\n# In error mode, checkers without error messages are disabled and for others,\n# only the ERROR messages are displayed, and no reports are done by default.\n#errors-only=\n\n# Always return a 0 (non-error) status code, even if lint errors are found.\n# This is primarily useful in continuous integration scripts.\n#exit-zero=\n\n# A comma-separated list of package or module names from where C extensions may\n# be loaded. Extensions are loading into the active Python interpreter and may\n# run arbitrary code.\nextension-pkg-allow-list=\n\n# A comma-separated list of package or module names from where C extensions may\n# be loaded. Extensions are loading into the active Python interpreter and may\n# run arbitrary code. (This is an alternative name to extension-pkg-allow-list\n# for backward compatibility.)\nextension-pkg-whitelist=\n\n# Return non-zero exit code if any of these messages/categories are detected,\n# even if score is above --fail-under value. Syntax same as enable. Messages\n# specified are enabled, while categories only check already-enabled messages.\nfail-on=\n\n# Specify a score threshold to be exceeded before program exits with error.\nfail-under=10\n\n# Interpret the stdin as a python script, whose filename needs to be passed as\n# the module_or_package argument.\n#from-stdin=\n\n# Files or directories to be skipped. They should be base names, not paths.\nignore=CVS\n\n# Add files or directories matching the regex patterns to the ignore-list. The\n# regex matches against paths and can be in Posix or Windows format.\nignore-paths=\n\n# Files or directories matching the regex patterns are skipped. The regex\n# matches against base names, not paths. The default value ignores Emacs file\n# locks\nignore-patterns=^\\.#\n\n# List of module names for which member attributes should not be checked\n# (useful for modules/projects where namespaces are manipulated during runtime\n# and thus existing member attributes cannot be deduced by static analysis). It\n# supports qualified module names, as well as Unix pattern matching.\nignored-modules=\n\n# Python code to execute, usually for sys.path manipulation such as\n# pygtk.require().\n#init-hook=\n\n# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the\n# number of processors available to use.\njobs=1\n\n# Control the amount of potential inferred values when inferring a single\n# object. This can help the performance when dealing with large functions or\n# complex, nested conditions.\nlimit-inference-results=100\n\n# List of plugins (as comma separated values of python module names) to load,\n# usually to register additional checkers.\nload-plugins=\n\n# Pickle collected data for later comparisons.\npersistent=yes\n\n# Minimum Python version to use for version dependent checks. Will default to\n# the version used to run pylint.\npy-version=3.6\n\n# Discover python modules and packages in the file system subtree.\nrecursive=no\n\n# When enabled, pylint would attempt to guess common misconfiguration and emit\n# user-friendly hints instead of false-positive error messages.\nsuggestion-mode=yes\n\n# Allow loading of arbitrary C extensions. Extensions are imported into the\n# active Python interpreter and may run arbitrary code.\nunsafe-load-any-extension=no\n\n# In verbose mode, extra non-checker-related info will be displayed.\n#verbose=\n\n\n[REPORTS]\n\n# Python expression which should return a score less than or equal to 10. You\n# have access to the variables 'fatal', 'error', 'warning', 'refactor',\n# 'convention', and 'info' which contain the number of messages in each\n# category, as well as 'statement' which is the total number of statements\n# analyzed. This score is used by the global evaluation report (RP0004).\nevaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))\n\n# Template used to display messages. This is a python new-style format string\n# used to format the message information. See doc for all details.\nmsg-template=\n\n# Set the output format. Available formats are text, parseable, colorized, json\n# and msvs (visual studio). You can also give a reporter class, e.g.\n# mypackage.mymodule.MyReporterClass.\n#output-format=\n\n# Tells whether to display a full report or only the messages.\nreports=no\n\n# Activate the evaluation score.\nscore=yes\n\n\n[MESSAGES CONTROL]\n\n# Only show warnings with the listed confidence levels. Leave empty to show\n# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,\n# UNDEFINED.\nconfidence=HIGH,\n           CONTROL_FLOW,\n           INFERENCE,\n           INFERENCE_FAILURE,\n           UNDEFINED\n\n# Disable the message, report, category or checker with the given id(s). You\n# can either give multiple identifiers separated by comma (,) or put this\n# option multiple times (only on the command line, not in the configuration\n# file where it should appear only once). You can also use \"--disable=all\" to\n# disable everything first and then re-enable specific checks. For example, if\n# you want to run only the similarities checker, you can use \"--disable=all\n# --enable=similarities\". If you want to run only the classes checker, but have\n# no Warning level messages displayed, use \"--disable=all --enable=classes\n# --disable=W\".\ndisable=raw-checker-failed,\n        bad-inline-option,\n        locally-disabled,\n        file-ignored,\n        suppressed-message,\n        useless-suppression,\n        deprecated-pragma,\n        use-symbolic-message-instead,\n        attribute-defined-outside-init,\n        invalid-name,\n        missing-docstring,\n        protected-access,\n        too-few-public-methods,\n        format, # handled by black\n\n# Enable the message, report, category or checker with the given id(s). You can\n# either give multiple identifier separated by comma (,) or put this option\n# multiple time (only on the command line, not in the configuration file where\n# it should appear only once). See also the \"--disable\" option for examples.\nenable=c-extension-no-member\n\n\n[BASIC]\n\n# Naming style matching correct argument names.\nargument-naming-style=snake_case\n\n# Regular expression matching correct argument names. Overrides argument-\n# naming-style. If left empty, argument names will be checked with the set\n# naming style.\n#argument-rgx=\n\n# Naming style matching correct attribute names.\nattr-naming-style=snake_case\n\n# Regular expression matching correct attribute names. Overrides attr-naming-\n# style. If left empty, attribute names will be checked with the set naming\n# style.\n#attr-rgx=\n\n# Bad variable names which should always be refused, separated by a comma.\nbad-names=foo,\n          bar,\n          baz,\n          toto,\n          tutu,\n          tata\n\n# Bad variable names regexes, separated by a comma. If names match any regex,\n# they will always be refused\nbad-names-rgxs=\n\n# Naming style matching correct class attribute names.\nclass-attribute-naming-style=any\n\n# Regular expression matching correct class attribute names. Overrides class-\n# attribute-naming-style. If left empty, class attribute names will be checked\n# with the set naming style.\n#class-attribute-rgx=\n\n# Naming style matching correct class constant names.\nclass-const-naming-style=UPPER_CASE\n\n# Regular expression matching correct class constant names. Overrides class-\n# const-naming-style. If left empty, class constant names will be checked with\n# the set naming style.\n#class-const-rgx=\n\n# Naming style matching correct class names.\nclass-naming-style=PascalCase\n\n# Regular expression matching correct class names. Overrides class-naming-\n# style. If left empty, class names will be checked with the set naming style.\n#class-rgx=\n\n# Naming style matching correct constant names.\nconst-naming-style=UPPER_CASE\n\n# Regular expression matching correct constant names. Overrides const-naming-\n# style. If left empty, constant names will be checked with the set naming\n# style.\n#const-rgx=\n\n# Minimum line length for functions/classes that require docstrings, shorter\n# ones are exempt.\ndocstring-min-length=-1\n\n# Naming style matching correct function names.\nfunction-naming-style=snake_case\n\n# Regular expression matching correct function names. Overrides function-\n# naming-style. If left empty, function names will be checked with the set\n# naming style.\n#function-rgx=\n\n# Good variable names which should always be accepted, separated by a comma.\ngood-names=i,\n           j,\n           k,\n           ex,\n           Run,\n           _\n\n# Good variable names regexes, separated by a comma. If names match any regex,\n# they will always be accepted\ngood-names-rgxs=\n\n# Include a hint for the correct naming format with invalid-name.\ninclude-naming-hint=no\n\n# Naming style matching correct inline iteration names.\ninlinevar-naming-style=any\n\n# Regular expression matching correct inline iteration names. Overrides\n# inlinevar-naming-style. If left empty, inline iteration names will be checked\n# with the set naming style.\n#inlinevar-rgx=\n\n# Naming style matching correct method names.\nmethod-naming-style=snake_case\n\n# Regular expression matching correct method names. Overrides method-naming-\n# style. If left empty, method names will be checked with the set naming style.\n#method-rgx=\n\n# Naming style matching correct module names.\nmodule-naming-style=snake_case\n\n# Regular expression matching correct module names. Overrides module-naming-\n# style. If left empty, module names will be checked with the set naming style.\n#module-rgx=\n\n# Colon-delimited sets of names that determine each other's naming style when\n# the name regexes allow several styles.\nname-group=\n\n# Regular expression which should only match function or class names that do\n# not require a docstring.\nno-docstring-rgx=^_\n\n# List of decorators that produce properties, such as abc.abstractproperty. Add\n# to this list to register other decorators that produce valid properties.\n# These decorators are taken in consideration only for invalid-name.\nproperty-classes=abc.abstractproperty\n\n# Regular expression matching correct type variable names. If left empty, type\n# variable names will be checked with the set naming style.\n#typevar-rgx=\n\n# Naming style matching correct variable names.\nvariable-naming-style=snake_case\n\n# Regular expression matching correct variable names. Overrides variable-\n# naming-style. If left empty, variable names will be checked with the set\n# naming style.\n#variable-rgx=\n\n\n[CLASSES]\n\n# Warn about protected attribute access inside special methods\ncheck-protected-access-in-special-methods=no\n\n# List of method names used to declare (i.e. assign) instance attributes.\ndefining-attr-methods=__init__,\n                      __new__,\n                      setUp,\n                      __post_init__\n\n# List of member names, which should be excluded from the protected access\n# warning.\nexclude-protected=_asdict,\n                  _fields,\n                  _replace,\n                  _source,\n                  _make\n\n# List of valid names for the first argument in a class method.\nvalid-classmethod-first-arg=cls\n\n# List of valid names for the first argument in a metaclass class method.\nvalid-metaclass-classmethod-first-arg=cls\n\n\n[DESIGN]\n\n# List of regular expressions of class ancestor names to ignore when counting\n# public methods (see R0903)\nexclude-too-few-public-methods=\n\n# List of qualified class names to ignore when counting class parents (see\n# R0901)\nignored-parents=\n\n# Maximum number of arguments for function / method.\nmax-args=5\n\n# Maximum number of attributes for a class (see R0902).\nmax-attributes=7\n\n# Maximum number of boolean expressions in an if statement (see R0916).\nmax-bool-expr=5\n\n# Maximum number of branch for function / method body.\nmax-branches=12\n\n# Maximum number of locals for function / method body.\nmax-locals=15\n\n# Maximum number of parents for a class (see R0901).\nmax-parents=7\n\n# Maximum number of public methods for a class (see R0904).\nmax-public-methods=20\n\n# Maximum number of return / yield for function / method body.\nmax-returns=6\n\n# Maximum number of statements in function / method body.\nmax-statements=50\n\n# Minimum number of public methods for a class (see R0903).\nmin-public-methods=2\n\n\n[EXCEPTIONS]\n\n# Exceptions that will emit a warning when caught.\novergeneral-exceptions=BaseException,\n                       Exception\n\n\n[FORMAT]\n\n# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.\nexpected-line-ending-format=\n\n# Regexp for a line that is allowed to be longer than the limit.\nignore-long-lines=^\\s*(# )?<?https?://\\S+>?$\n\n# Number of spaces of indent required inside a hanging or continued line.\nindent-after-paren=4\n\n# String used as indentation unit. This is usually \"    \" (4 spaces) or \"\\t\" (1\n# tab).\nindent-string='    '\n\n# Maximum number of characters on a single line.\nmax-line-length=100\n\n# Maximum number of lines in a module.\nmax-module-lines=1000\n\n# Allow the body of a class to be on the same line as the declaration if body\n# contains single statement.\nsingle-line-class-stmt=no\n\n# Allow the body of an if to be on the same line as the test if there is no\n# else.\nsingle-line-if-stmt=no\n\n\n[IMPORTS]\n\n# List of modules that can be imported at any level, not just the top level\n# one.\nallow-any-import-level=\n\n# Allow wildcard imports from modules that define __all__.\nallow-wildcard-with-all=no\n\n# Deprecated modules which should not be used, separated by a comma.\ndeprecated-modules=\n\n# Output a graph (.gv or any supported image format) of external dependencies\n# to the given file (report RP0402 must not be disabled).\next-import-graph=\n\n# Output a graph (.gv or any supported image format) of all (i.e. internal and\n# external) dependencies to the given file (report RP0402 must not be\n# disabled).\nimport-graph=\n\n# Output a graph (.gv or any supported image format) of internal dependencies\n# to the given file (report RP0402 must not be disabled).\nint-import-graph=\n\n# Force import order to recognize a module as part of the standard\n# compatibility libraries.\nknown-standard-library=\n\n# Force import order to recognize a module as part of a third party library.\nknown-third-party=enchant\n\n# Couples of modules and preferred modules, separated by a comma.\npreferred-modules=\n\n\n[LOGGING]\n\n# The type of string formatting that logging methods do. `old` means using %\n# formatting, `new` is for `{}` formatting.\nlogging-format-style=old\n\n# Logging modules to check that the string format arguments are in logging\n# function parameter format.\nlogging-modules=logging\n\n\n[MISCELLANEOUS]\n\n# List of note tags to take in consideration, separated by a comma.\nnotes=FIXME,\n      XXX,\n      TODO\n\n# Regular expression of note tags to take in consideration.\nnotes-rgx=\n\n\n[REFACTORING]\n\n# Maximum number of nested blocks for function / method body\nmax-nested-blocks=5\n\n# Complete name of functions that never returns. When checking for\n# inconsistent-return-statements if a never returning function is called then\n# it will be considered as an explicit return statement and no message will be\n# printed.\nnever-returning-functions=sys.exit,argparse.parse_error\n\n\n[SIMILARITIES]\n\n# Comments are removed from the similarity computation\nignore-comments=yes\n\n# Docstrings are removed from the similarity computation\nignore-docstrings=yes\n\n# Imports are removed from the similarity computation\nignore-imports=yes\n\n# Signatures are removed from the similarity computation\nignore-signatures=yes\n\n# Minimum lines number of a similarity.\nmin-similarity-lines=4\n\n\n[SPELLING]\n\n# Limits count of emitted suggestions for spelling mistakes.\nmax-spelling-suggestions=4\n\n# Spelling dictionary name. Available dictionaries: none. To make it work,\n# install the 'python-enchant' package.\nspelling-dict=\n\n# List of comma separated words that should be considered directives if they\n# appear at the beginning of a comment and should not be checked.\nspelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:\n\n# List of comma separated words that should not be checked.\nspelling-ignore-words=\n\n# A path to a file that contains the private dictionary; one word per line.\nspelling-private-dict-file=\n\n# Tells whether to store unknown words to the private dictionary (see the\n# --spelling-private-dict-file option) instead of raising a message.\nspelling-store-unknown-words=no\n\n\n[STRING]\n\n# This flag controls whether inconsistent-quotes generates a warning when the\n# character used as a quote delimiter is used inconsistently within a module.\ncheck-quote-consistency=no\n\n# This flag controls whether the implicit-str-concat should generate a warning\n# on implicit string concatenation in sequences defined over several lines.\ncheck-str-concat-over-line-jumps=no\n\n\n[TYPECHECK]\n\n# List of decorators that produce context managers, such as\n# contextlib.contextmanager. Add to this list to register other decorators that\n# produce valid context managers.\ncontextmanager-decorators=contextlib.contextmanager\n\n# List of members which are set dynamically and missed by pylint inference\n# system, and so shouldn't trigger E1101 when accessed. Python regular\n# expressions are accepted.\ngenerated-members=\n\n# Tells whether to warn about missing members when the owner of the attribute\n# is inferred to be None.\nignore-none=yes\n\n# This flag controls whether pylint should warn about no-member and similar\n# checks whenever an opaque object is returned when inferring. The inference\n# can return multiple potential results while evaluating a Python object, but\n# some branches might not be evaluated, which results in partial inference. In\n# that case, it might be useful to still emit no-member and other checks for\n# the rest of the inferred objects.\nignore-on-opaque-inference=yes\n\n# List of symbolic message names to ignore for Mixin members.\nignored-checks-for-mixins=no-member,\n                          not-async-context-manager,\n                          not-context-manager,\n                          attribute-defined-outside-init\n\n# List of class names for which member attributes should not be checked (useful\n# for classes with dynamically set attributes). This supports the use of\n# qualified names.\nignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace\n\n# Show a hint with possible names when a member name was not found. The aspect\n# of finding the hint is based on edit distance.\nmissing-member-hint=yes\n\n# The minimum edit distance a name should have in order to be considered a\n# similar match for a missing member name.\nmissing-member-hint-distance=1\n\n# The total number of similar names that should be taken in consideration when\n# showing a hint for a missing member.\nmissing-member-max-choices=1\n\n# Regex pattern to define which classes are considered mixins.\nmixin-class-rgx=.*[Mm]ixin\n\n# List of decorators that change the signature of a decorated function.\nsignature-mutators=\n\n\n[VARIABLES]\n\n# List of additional names supposed to be defined in builtins. Remember that\n# you should avoid defining new builtins when possible.\nadditional-builtins=\n\n# Tells whether unused global variables should be treated as a violation.\nallow-global-unused-variables=yes\n\n# List of names allowed to shadow builtins\nallowed-redefined-builtins=\n\n# List of strings which can identify a callback function by name. A callback\n# name must start or end with one of those strings.\ncallbacks=cb_,\n          _cb\n\n# A regular expression matching the name of dummy variables (i.e. expected to\n# not be used).\ndummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_\n\n# Argument names that match this expression will be ignored. Default to name\n# with leading underscore.\nignored-argument-names=_.*|^ignored_|^unused_\n\n# Tells whether we should check for unused import in __init__ files.\ninit-import=no\n\n# List of qualified module names which can have objects that can redefine\n# builtins.\nredefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io\n"
  },
  {
    "path": ".python-version",
    "content": "3.10\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at the [discord server](https://discord.gg/yqNvvDMYpq).\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "\n# Contributing to Reddit Video Maker Bot 🎥\n\nThanks for taking the time to contribute! ❤️\n\nAll types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for the maintainers and smooth out the experience for all involved. We are looking forward to your contributions. 🎉\n\n> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:\n>\n> - ⭐ Star the project\n> - 📣 Tweet about it\n> - 🌲 Refer this project in your project's readme\n\n## Table of Contents\n\n- [Contributing to Reddit Video Maker Bot 🎥](#contributing-to-reddit-video-maker-bot-)\n  - [Table of Contents](#table-of-contents)\n  - [I Have a Question](#i-have-a-question)\n  - [I Want To Contribute](#i-want-to-contribute)\n    - [Reporting Bugs](#reporting-bugs)\n      - [How Do I Submit a Good Bug Report?](#how-do-i-submit-a-good-bug-report)\n    - [Suggesting Enhancements](#suggesting-enhancements)\n      - [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)\n    - [Your First Code Contribution](#your-first-code-contribution)\n      - [Your environment](#your-environment)\n      - [Making your first PR](#making-your-first-pr)\n    - [Improving The Documentation](#improving-the-documentation)\n\n## I Have a Question\n\n> If you want to ask a question, we assume that you have read the available [Documentation](https://reddit-video-maker-bot.netlify.app/).\n\nBefore you ask a question, it is best to search for existing [Issues](https://github.com/elebumm/RedditVideoMakerBot/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.\n\nIf you then still feel the need to ask a question and need clarification, we recommend the following:\n\n- Open an [Issue](https://github.com/elebumm/RedditVideoMakerBot/issues/new).\n- Provide as much context as you can about what you're running into.\n- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.\n\nWe will then take care of the issue as soon as possible.\n\nAdditionally, there is a [Discord Server](https://discord.gg/swqtb7AsNQ) for any questions you may have\n\n## I Want To Contribute\n\n### Reporting Bugs\n\n<details><summary><h4>Before Submitting a Bug Report</h4></summary>\n\nA good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.\n\n- Make sure that you are using the latest version.\n- Determine if your bug is really a bug and not an error on your side e.g., using incompatible environment components/versions (Make sure that you have read the [documentation](https://luka-hietala.gitbook.io/documentation-for-the-reddit-bot/). If you are looking for support, you might want to check [this section](#i-have-a-question)).\n- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [issues](https://github.com/elebumm/RedditVideoMakerBot/).\n- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue - you probably aren't the first to get the error!\n- Collect information about the bug:\n  - Stack trace (Traceback) - preferably formatted in a code block.\n  - OS, Platform and Version (Windows, Linux, macOS, x86, ARM)\n  - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.\n  - Your input and the output\n  - Is the issue reproducible? Does it exist in previous versions?\n\n#### How Do I Submit a Good Bug Report?\n\nWe use GitHub issues to track bugs and errors. If you run into an issue with the project:\n\n- Open an [Issue](https://github.com/elebumm/RedditVideoMakerBot/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)\n- Explain the behavior you would expect and the actual behavior.\n- Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.\n- Provide the information you collected in the previous section.\n\nOnce it's filed:\n\n- The project team will label the issue accordingly.\n- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will try to support you as best as they can, but you may not receive an instant.\n- If the team discovers that this is an issue it will be marked `bug` or `error`, as well as possibly other tags relating to the nature of the error), and the issue will be left to be [implemented by someone](#your-first-code-contribution).\n</details>\n\n### Suggesting Enhancements\n\nThis section guides you through submitting an enhancement suggestion for Reddit Video Maker Bot, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.\n\n<details><summary><h4>Before Submitting an Enhancement</h4></summary>\n\n- Make sure that you are using the latest version.\n- Read the [documentation](https://luka-hietala.gitbook.io/documentation-for-the-reddit-bot/) carefully and find out if the functionality is already covered, maybe by an individual configuration.\n- Perform a [search](https://github.com/elebumm/RedditVideoMakerBot/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.\n- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset.\n\n#### How Do I Submit a Good Enhancement Suggestion?\n\nEnhancement suggestions are tracked as [GitHub issues](https://github.com/elebumm/RedditVideoMakerBot/issues).\n\n- Use a **clear and descriptive title** for the issue to identify the suggestion.\n- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.\n- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.\n- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. <!-- this should only be included if the project has a GUI -->\n- **Explain why this enhancement would be useful** to most users. You may also want to point out the other projects that solved it better and which could serve as inspiration.\n\n</details>\n\n### Your First Code Contribution\n\n#### Your environment\n\nYou development environment should follow the requirements stated in the [README file](README.md). If you are not using the specified versions, **please reference this in your pull request**, so reviewers can test your code on both versions.\n\n#### Setting up your development repository\n\nThese steps are only specified for beginner developers trying to contribute to this repository.\nIf you know how to make a fork and clone, you can skip these steps.\n\nBefore contributing, follow these steps (if you are a beginner)\n\n- Create a fork of this repository to your personal account\n- Clone the repo to your computer\n- Make sure that you have all dependencies installed\n- Run `python main.py` to make sure that the program is working\n- Now, you are all setup to contribute your own features to this repo!\n\nEven if you are a beginner to working with python or contributing to open source software,\ndon't worry! You can still try contributing even to the documentation!\n\n(\"Setting up your development repository\" was written by a beginner developer themselves!)\n\n\n#### Making your first PR\n\nWhen making your PR, follow these guidelines:\n\n- Your branch has a base of _develop_, **not** _master_\n- You are merging your branch into the _develop_ branch\n- You link any issues that are resolved or fixed by your changes. (this is done by typing \"Fixes #\\<issue number\\>\") in your pull request\n- Where possible, you have used `git pull --rebase`, to avoid creating unnecessary merge commits\n- You have meaningful commits, and if possible, follow the commit style guide of `type: explanation`\n- Here are the commit types:\n - **feat** - a new feature\n - **fix** - a bug fix\n - **docs** - a change to documentation / commenting\n - **style** - formatting changes - does not impact code\n - **refactor** - refactored code\n - **chore** - updating configs, workflows etc - does not impact code\n\n### Improving The Documentation\n\nAll updates to the documentation should be made in a pull request to [this repo](https://github.com/LukaHietala/RedditVideoMakerBot-website)\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.10.14-slim\n\nRUN apt update\nRUN apt-get install -y ffmpeg\nRUN apt install python3-pip -y\n\nRUN mkdir /app\nADD . /app\nWORKDIR /app\nRUN pip install -r requirements.txt\n\nCMD [\"python3\", \"main.py\"]\n"
  },
  {
    "path": "GUI/backgrounds.html",
    "content": "{% extends \"layout.html\" %}\n{% block main %}\n\n<!-- Delete Background Modal -->\n<div class=\"modal fade\" id=\"deleteBtnModal\" tabindex=\"-1\" role=\"dialog\" aria-hidden=\"true\">\n    <div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\">\n                <h5 class=\"modal-title\">Delete background</h5>\n            </div>\n            <div class=\"modal-body\">\n                Are you sure you want to delete this background?\n            </div>\n            <div class=\"modal-footer\">\n                <button type=\"button\" class=\"btn btn-secondary\" data-dismiss=\"modal\">Close</button>\n                <form action=\"background/delete\" method=\"post\">\n                    <input type=\"hidden\" id=\"background-key\" name=\"background-key\" value=\"\">\n                    <button type=\"submit\" class=\"btn btn-danger\">Delete</button>\n                </form>\n            </div>\n        </div>\n    </div>\n</div>\n\n<!-- Add Background Modal -->\n<div class=\"modal fade\" id=\"backgroundAddModal\" tabindex=\"-1\" role=\"dialog\" aria-hidden=\"true\">\n    <div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\">\n                <h5 class=\"modal-title\">Add background video</h5>\n            </div>\n            <div class=\"modal-body\">\n\n                <!-- Add video form -->\n                <form id=\"addBgForm\" action=\"background/add\" method=\"post\" novalidate>\n                    <div class=\"form-group row\">\n                        <label class=\"col-4 col-form-label\" for=\"youtube_uri\">YouTube URI</label>\n                        <div class=\"col-8\">\n                            <div class=\"input-group\">\n                                <div class=\"input-group-text\">\n                                    <i class=\"bi bi-youtube\"></i>\n                                </div>\n                                <input name=\"youtube_uri\" placeholder=\"https://www.youtube.com/watch?v=...\" type=\"text\"\n                                    class=\"form-control\">\n                            </div>\n                            <span id=\"feedbackYT\" class=\"form-text feedback-invalid\"></span>\n                        </div>\n                    </div>\n                    <div class=\"form-group row\">\n                        <label for=\"filename\" class=\"col-4 col-form-label\">Filename</label>\n                        <div class=\"col-8\">\n                            <div class=\"input-group\">\n                                <div class=\"input-group-text\">\n                                    <i class=\"bi bi-file-earmark\"></i>\n                                </div>\n                                <input name=\"filename\" placeholder=\"Example: cool-background\" type=\"text\"\n                                    class=\"form-control\">\n                            </div>\n                            <span id=\"feedbackFilename\" class=\"form-text feedback-invalid\"></span>\n                        </div>\n                    </div>\n                    <div class=\"form-group row\">\n                        <label for=\"citation\" class=\"col-4 col-form-label\">Credits (owner of the video)</label>\n                        <div class=\"col-8\">\n                            <div class=\"input-group\">\n                                <div class=\"input-group-text\">\n                                    <i class=\"bi bi-person-circle\"></i>\n                                </div>\n                                <input name=\"citation\" placeholder=\"YouTube Channel\" type=\"text\" class=\"form-control\">\n                            </div>\n                            <span class=\"form-text text-muted\">Include the channel name of the\n                                owner of the background video you are adding.</span>\n                        </div>\n                    </div>\n                    <div class=\"form-group row\">\n                        <label for=\"position\" class=\"col-4 col-form-label\">Position of screenshots</label>\n                        <div class=\"col-8\">\n                            <div class=\"input-group\">\n                                <div class=\"input-group-text\">\n                                    <i class=\"bi bi-arrows-fullscreen\"></i>\n                                </div>\n                                <input name=\"position\" placeholder=\"Example: center\" type=\"text\" class=\"form-control\">\n                            </div>\n                            <span class=\"form-text text-muted\">Advanced option (you can leave it\n                                empty). Valid options are \"center\" and decimal numbers</span>\n                        </div>\n                    </div>\n            </div>\n            <div class=\"modal-footer\">\n                <button type=\"button\" class=\"btn btn-secondary\" data-dismiss=\"modal\">Close</button>\n                <button name=\"submit\" type=\"submit\" class=\"btn btn-success\">Add background</button>\n                </form>\n            </div>\n        </div>\n    </div>\n</div>\n\n<main>\n    <div class=\"album py-2 bg-light\">\n        <div class=\"container\">\n\n            <div class=\"row justify-content-between mt-2\">\n                <div class=\"col-12 col-md-3 mb-3\">\n                    <input type=\"text\" class=\"form-control searchFilter\" placeholder=\"Search backgrounds\"\n                        onkeyup=\"searchFilter()\">\n                </div>\n                <div class=\"col-12 col-md-2 mb-3\">\n                    <button type=\"button\" class=\"btn btn-primary form-control\" data-toggle=\"modal\"\n                        data-target=\"#backgroundAddModal\">\n                        Add background video\n                    </button>\n                </div>\n            </div>\n\n            <div class=\"grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3\" id=\"backgrounds\">\n\n            </div>\n        </div>\n    </div>\n</main>\n\n<script>\n    var keys = [];\n    var youtube_urls = [];\n\n    // Show background videos\n    $(document).ready(function () {\n        $.getJSON(\"backgrounds.json\",\n            function (data) {\n                delete data[\"__comment\"];\n                var background = '';\n                $.each(data, function (key, value) {\n                    // Add YT urls and keys (for validation)\n                    keys.push(key);\n                    youtube_urls.push(value[0]);\n\n                    background += '<div class=\"col\">';\n                    background += '<div class=\"card shadow-sm\">';\n                    background += '<iframe class=\"bd-placeholder-img card-img-top\" width=\"100%\" height=\"225\" src=\"https://www.youtube-nocookie.com/embed/' + value[0].split(\"?v=\")[1] + '\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe>';\n                    background += '<div class=\"card-body\">';\n                    background += '<p class=\"card-text\">' + value[2] + ' • ' + key + '</p>';\n                    background += '<div class=\"d-flex justify-content-between align-items-center\">';\n                    background += '<div class=\"btn-group\">';\n                    background += '<button type=\"button\" class=\"btn btn-outline-danger\" data-toggle=\"modal\" data-target=\"#deleteBtnModal\" data-background-key=\"' + key + '\">Delete</button>';\n                    background += '</div>';\n                    background += '</div>';\n                    background += '</div>';\n                    background += '</div>';\n                    background += '</div>';\n                });\n\n                $('#backgrounds').append(background);\n            });\n    });\n\n    // Add background key when deleting\n    $('#deleteBtnModal').on('show.bs.modal', function (event) {\n        var button = $(event.relatedTarget);\n        var key = button.data('background-key');\n\n        $('#background-key').prop('value', key);\n    });\n\n    var searchFilter = () => {\n        const input = document.querySelector(\".searchFilter\");\n        const cards = document.getElementsByClassName(\"col\");\n        console.log(cards[1])\n        let filter = input.value\n        for (let i = 0; i < cards.length; i++) {\n            let title = cards[i].querySelector(\".card-text\");\n            if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {\n                cards[i].classList.remove(\"d-none\")\n            } else {\n                cards[i].classList.add(\"d-none\")\n            }\n        }\n    }\n\n    // Validate form\n    $(\"#addBgForm\").submit(function (event) {\n        $(\"#addBgForm input\").each(function () {\n            if (!(validate($(this)))) {\n                event.preventDefault();\n                event.stopPropagation();\n            }\n        });\n    });\n\n    $('#addBgForm input[type=\"text\"]').on(\"keyup\", function () {\n        validate($(this));\n    });\n\n    function validate(object) {\n        let bool = check(object.prop(\"name\"), object.prop(\"value\"));\n\n        // Change class\n        if (bool) {\n            object.removeClass(\"is-invalid\");\n            object.addClass(\"is-valid\");\n        }\n        else {\n            object.removeClass(\"is-valid\");\n            object.addClass(\"is-invalid\");\n        }\n\n        return bool;\n\n        // Check values (return true/false)\n        function check(name, value) {\n            if (name == \"youtube_uri\") {\n                // URI validation\n                let regex = /(?:\\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/;\n                if (!(regex.test(value))) {\n                    $(\"#feedbackYT\").html(\"Invalid URI\");\n                    $(\"#feedbackYT\").show();\n                    return false;\n                }\n\n                // Check if this background already exists\n                if (youtube_urls.includes(value)) {\n                    $(\"#feedbackYT\").html(\"This background is already added\");\n                    $(\"#feedbackYT\").show();\n                    return false;\n                }\n\n                $(\"#feedbackYT\").hide();\n                return true;\n            }\n\n            if (name == \"filename\") {\n                // Check if key is already taken\n                if (keys.includes(value)) {\n                    $(\"#feedbackFilename\").html(\"This filename is already taken\");\n                    $(\"#feedbackFilename\").show();\n                    return false;\n                }\n\n                let regex = /^([a-zA-Z0-9\\s_-]{1,100})$/;\n                if (!(regex.test(value))) {\n                    return false;\n                }\n\n                return true;\n            }\n\n            if (name == \"citation\") {\n                if (value.trim()) {\n                    return true;\n                }\n            }\n\n            if (name == \"position\") {\n                if (!(value == \"center\" || value.length == 0 || value % 1 == 0)) {\n                    return false;\n                }\n\n                return true;\n            }\n        }\n    }\n</script>\n\n{% endblock %}"
  },
  {
    "path": "GUI/index.html",
    "content": "{% extends \"layout.html\" %}\n{% block main %}\n\n<main>\n    <div class=\"album py-2 bg-light\">\n        <div class=\"container\">\n\n            <div class=\"row mt-2\">\n                <div class=\"col-12 col-md-3 mb-3\">\n                    <input type=\"text\" class=\"form-control searchFilter\" placeholder=\"Search videos\"\n                        aria-label=\"Search videos\" onkeyup=\"searchFilter()\">\n                </div>\n            </div>\n\n            <div class=\"grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3\" id=\"videos\">\n\n            </div>\n        </div>\n    </div>\n</main>\n\n<script>\n    const intervals = [\n        { label: 'year', seconds: 31536000 },\n        { label: 'month', seconds: 2592000 },\n        { label: 'day', seconds: 86400 },\n        { label: 'hour', seconds: 3600 },\n        { label: 'minute', seconds: 60 },\n        { label: 'second', seconds: 1 }\n    ];\n\n    function timeSince(date) {\n        const seconds = Math.floor((Date.now() / 1000 - date));\n        const interval = intervals.find(i => i.seconds < seconds);\n        const count = Math.floor(seconds / interval.seconds);\n        return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;\n    }\n\n    $(document).ready(function () {\n        $.getJSON(\"videos.json\",\n            function (data) {\n                data.sort((b, a) => a['time'] - b['time'])\n                var video = '';\n                $.each(data, function (key, value) {\n                    video += '<div class=\"col\">';\n                    video += '<div class=\"card shadow-sm\">';\n                    //keeping original themed image card for future thumbnail usage video += '<svg class=\"bd-placeholder-img card-img-top\" width=\"100%\" height=\"225\" xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-label=\"Placeholder: Thumbnail\" preserveAspectRatio=\"xMidYMid slice\" focusable=\"false\"><title>Placeholder</title><rect width=\"100%\" height=\"100%\" fill=\"#55595c\"/><text x=\"50%\" y=\"50%\" fill=\"#eceeef\" dy=\".3em\">r/'+value.subreddit+'</text></svg>';\n\n                    video += '<div class=\"card-body\">';\n                    video += '<p class=\"card-text\">r/' + value.subreddit + ' • ' + checkTitle(value.reddit_title, value.filename) + '</p>';\n                    video += '<div class=\"d-flex justify-content-between align-items-center\">';\n                    video += '<div class=\"btn-group\">';\n                    video += '<a href=\"https://www.reddit.com/r/' + value.subreddit + '/comments/' + value.id + '/\" class=\"btn btn-sm btn-outline-secondary\" target=\"_blank\">View</a>';\n                    video += '<a href=\"http://localhost:4000/results/' + value.subreddit + '/' + value.filename + '\" class=\"btn btn-sm btn-outline-secondary\" download>Download</a>';\n                    video += '</div>';\n                    video += '<div class=\"btn-group\">';\n                    video += '<button type=\"button\" data-toggle=\"tooltip\" id=\"copy\" data-original-title=\"Copy to clipboard\" class=\"btn btn-sm btn-outline-secondary\" data-clipboard-text=\"' + getCopyData(value.subreddit, value.reddit_title, value.filename, value.background_credit) + '\"><i class=\"bi bi-card-text\"></i></button>';\n                    video += '<button type=\"button\" data-toggle=\"tooltip\" id=\"copy\" data-original-title=\"Copy to clipboard\" class=\"btn btn-sm btn-outline-secondary\" data-clipboard-text=\"' + checkTitle(value.reddit_title, value.filename) + ' #Shorts #reddit\"><i class=\"bi bi-youtube\"></i></button>';\n                    video += '<button type=\"button\" data-toggle=\"tooltip\" id=\"copy\" data-original-title=\"Copy to clipboard\" class=\"btn btn-sm btn-outline-secondary\" data-clipboard-text=\"' + checkTitle(value.reddit_title, value.filename) + ' #reddit\"><i class=\"bi bi-instagram\"></i></button>';\n                    video += '</div>';\n                    video += '<small class=\"text-muted\">' + timeSince(value.time) + '</small>';\n                    video += '</div>';\n                    video += '</div>';\n                    video += '</div>';\n                    video += '</div>';\n\n                });\n\n                $('#videos').append(video);\n            });\n    });\n\n    $(document).ready(function () {\n        $('[data-toggle=\"tooltip\"]').tooltip();\n        $('[data-toggle=\"tooltip\"]').on('click', function () {\n            $(this).tooltip('hide');\n        });\n    });\n\n    $('#copy').tooltip({\n        trigger: 'click',\n        placement: 'bottom'\n    });\n\n    function setTooltip(btn, message) {\n        $(btn).tooltip('hide')\n            .attr('data-original-title', message)\n            .tooltip('show');\n    }\n\n    function hoverTooltip(btn, message) {\n        $(btn).tooltip('hide')\n            .attr('data-original-title', message)\n            .tooltip('show');\n    }\n\n    function hideTooltip(btn) {\n        setTimeout(function () {\n            $(btn).tooltip('hide');\n        }, 1000);\n    }\n\n    function disposeTooltip(btn) {\n        setTimeout(function () {\n            $(btn).tooltip('dispose');\n        }, 1500);\n    }\n\n    var clipboard = new ClipboardJS('#copy');\n\n    clipboard.on('success', function (e) {\n        e.clearSelection();\n        console.info('Action:', e.action);\n        console.info('Text:', e.text);\n        console.info('Trigger:', e.trigger);\n        setTooltip(e.trigger, 'Copied!');\n        hideTooltip(e.trigger);\n        disposeTooltip(e.trigger);\n    });\n\n    clipboard.on('error', function (e) {\n        console.error('Action:', e.action);\n        console.error('Trigger:', e.trigger);\n        setTooltip(e.trigger, fallbackMessage(e.action));\n        hideTooltip(e.trigger);\n    });\n\n    function getCopyData(subreddit, reddit_title, filename, background_credit) {\n\n        if (subreddit == undefined) {\n            subredditCopy = \"\";\n        } else {\n            subredditCopy = \"r/\" + subreddit + \"\\n\\n\";\n        }\n\n        const file = filename.slice(0, -4);\n        if (reddit_title == file) {\n            titleCopy = reddit_title;\n        } else {\n            titleCopy = file;\n        }\n\n        var copyData = \"\";\n        copyData += subredditCopy;\n        copyData += titleCopy;\n        copyData += \"\\n\\nBackground credit: \" + background_credit;\n        return copyData;\n    }\n\n    function getLink(subreddit, id, reddit_title) {\n        if (subreddit == undefined) {\n            return reddit_title;\n        } else {\n            return \"<a target='_blank' href='https://www.reddit.com/r/\" + subreddit + \"/comments/\" + id + \"/'>\" + reddit_title + \"</a>\";\n        }\n    }\n\n    function checkTitle(reddit_title, filename) {\n        const file = filename.slice(0, -4);\n        if (reddit_title == file) {\n            return reddit_title;\n        } else {\n            return file;\n        }\n    }\n\n    var searchFilter = () => {\n        const input = document.querySelector(\".searchFilter\");\n        const cards = document.getElementsByClassName(\"col\");\n        console.log(cards[1])\n        let filter = input.value\n        for (let i = 0; i < cards.length; i++) {\n            let title = cards[i].querySelector(\".card-text\");\n            if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {\n                cards[i].classList.remove(\"d-none\")\n            } else {\n                cards[i].classList.add(\"d-none\")\n            }\n        }\n    }\n</script>\n{% endblock %}"
  },
  {
    "path": "GUI/layout.html",
    "content": "<html lang=\"en\">\n\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <meta http-equiv=\"cache-control\" content=\"no-cache\" />\n    <title>RedditVideoMakerBot</title>\n    <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/css/bootstrap.min.css\"\n        integrity=\"sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO\" crossorigin=\"anonymous\">\n    <link href=\"https://getbootstrap.com/docs/5.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\">\n    <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css\">\n\n    <style>\n        .bd-placeholder-img {\n            font-size: 1.125rem;\n            text-anchor: middle;\n            -webkit-user-select: none;\n            -moz-user-select: none;\n            user-select: none;\n        }\n\n        .feedback-invalid {\n            color: #dc3545;\n        }\n\n        @media (min-width: 768px) {\n            .bd-placeholder-img-lg {\n                font-size: 3.5rem;\n            }\n        }\n\n        .bi {\n            vertical-align: -.125em;\n            fill: currentColor;\n        }\n\n        .nav {\n            display: flex;\n            flex-wrap: nowrap;\n            padding-bottom: 1rem;\n            margin-top: -1px;\n            overflow-x: auto;\n            text-align: center;\n            white-space: nowrap;\n            -webkit-overflow-scrolling: touch;\n        }\n\n        #tooltip {\n            background-color: #333;\n            color: white;\n            padding: 5px 10px;\n            border-radius: 4px;\n            font-size: 13px;\n        }\n\n        .tooltip-inner {\n            max-width: 500px !important;\n        }\n        #hard-reload {\n            cursor: pointer;\n            color: darkblue;\n        }\n        #hard-reload:hover {\n            color: blue;\n        }\n    </style>\n</head>\n\n<script src=\"https://code.jquery.com/jquery-3.1.1.js\" integrity=\"sha256-16cdPddA6VdVInumRGo6IbivbERE8p7CQR3HzTBuELA=\"\n    crossorigin=\"anonymous\"></script>\n<script src=\"https://cdn.jsdelivr.net/npm/popper.js@1.14.3/dist/umd/popper.min.js\"\n    integrity=\"sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49\"\n    crossorigin=\"anonymous\"></script>\n<script src=\"https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/js/bootstrap.min.js\"\n    integrity=\"sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy\"\n    crossorigin=\"anonymous\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js\"></script>\n<script src=\"https://unpkg.com/isotope-layout@3/dist/isotope.pkgd.js\"></script>\n\n<body>\n    <header>\n        {% if get_flashed_messages() %}\n        {% for category, message in get_flashed_messages(with_categories=true) %}\n\n        {% if category == \"error\" %}\n        <div class=\"alert alert-danger mb-0 text-center\" role=\"alert\">\n            {{ message }}\n        </div>\n\n        {% else %}\n        <div class=\"alert alert-success mb-0 text-center\" role=\"alert\">\n            {{ message }}\n        </div>\n        {% endif %}\n        {% endfor %}\n        {% endif %}\n        <nav class=\"navbar navbar-expand-lg navbar-dark bg-dark\">\n            <div class=\"container\">\n                <a href=\"/\" class=\"navbar-brand d-flex align-items-center\">\n                    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" fill=\"none\" stroke=\"currentColor\"\n                        stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" aria-hidden=\"true\" class=\"me-2\"\n                        viewBox=\"0 0 24 24\">\n                        <path d=\"M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z\" />\n                        <circle cx=\"12\" cy=\"13\" r=\"4\" />\n                    </svg>\n                    <strong>RedditVideoMakerBot</strong>\n                </a>\n\n                <div class=\"collapse navbar-collapse\">\n                    <ul class=\"navbar-nav mr-auto\">\n                        <li class=\"nav-item\">\n                            <a class=\"nav-link\" href=\"backgrounds\">Background Manager</a>\n                        </li>\n                        <li class=\"nav-item\">\n                            <a class=\"nav-link\" href=\"settings\">Settings</a>\n                        </li>\n                    </ul>\n                    <!-- Future feature\n                    <ul class=\"navbar-nav\">\n                        <li class=\"nav-item\">\n                            <button class=\"btn btn-outline-success mr-auto mt-2 mt-lg-0\">Create new short</button>\n                        </li>\n                    </ul>\n                    -->\n                </div>\n            </div>\n        </nav>\n    </header>\n\n    {% block main %}{% endblock %}\n\n    <footer class=\"text-muted py-5\">\n        <div class=\"container\">\n            <p class=\"float-end mb-1\">\n                <a href=\"#\">Back to top</a>\n            </p>\n            <p class=\"mb-1\"><a href=\"https://getbootstrap.com/docs/5.2/examples/album/\" target=\"_blank\">Album</a>\n                Example\n                Theme by &copy; Bootstrap. <a\n                    href=\"https://github.com/elebumm/RedditVideoMakerBot/blob/master/README.md#developers-and-maintainers\"\n                    target=\"_blank\">Developers and Maintainers</a></p>\n            <p class=\"mb-0\">If your data is not refreshing, try to hard reload(Ctrl + F5) or click <a id=\"hard-reload\">this</a> and visit your local\n\n                <strong>{{ file }}</strong> file.\n            </p>\n        </div>\n    </footer>\n    <script>\n        document.getElementById(\"hard-reload\").addEventListener(\"click\", function () {\n            window.location.reload(true);\n        });\n    </script>\n</body>\n\n</html>"
  },
  {
    "path": "GUI/settings.html",
    "content": "{% extends \"layout.html\" %}\n{% block main %}\n\n<main>\n    <br>\n    <div class=\"container\">\n        <form id=\"settingsForm\" action=\"/settings\" method=\"post\" novalidate>\n\n            <!-- Reddit Credentials -->\n            <p class=\"h4\">Reddit Credentials</p>\n            <div class=\"row mb-2\">\n                <label for=\"client_id\" class=\"col-4\">Client ID</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-person\"></i>\n                        </div>\n                        <input name=\"client_id\" value=\"{{ data.client_id }}\" placeholder=\"Your Reddit app's client ID\"\n                            type=\"text\" class=\"form-control\" data-toggle=\"tooltip\"\n                            data-original-title='Text under \"personal use script\" on www.reddit.com/prefs/apps'>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"client_secret\" class=\"col-4\">Client Secret</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-key-fill\"></i>\n                        </div>\n                        <input name=\"client_secret\" value=\"{{ data.client_secret }}\"\n                            placeholder=\"Your Reddit app's client secret\" type=\"text\" class=\"form-control\"\n                            data-toggle=\"tooltip\" data-original-title='\"Secret\" on www.reddit.com/prefs/apps'>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"username\" class=\"col-4\">Reddit Username</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-person-fill\"></i>\n                        </div>\n                        <input name=\"username\" value=\"{{ data.username }}\" placeholder=\"Your Reddit account's username\"\n                            type=\"text\" class=\"form-control\">\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"password\" class=\"col-4\">Reddit Password</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-lock-fill\"></i>\n                        </div>\n                        <input name=\"password\" value=\"{{ data.password }}\" placeholder=\"Your Reddit account's password\"\n                            type=\"password\" class=\"form-control\">\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label class=\"col-4\">Do you have Reddit 2FA enabled?</label>\n                <div class=\"col-8\">\n                    <div class=\"form-check form-switch\">\n                        <input name=\"2fa\" class=\"form-check-input\" type=\"checkbox\" value=\"True\" data-toggle=\"tooltip\"\n                            data-original-title='Check it if you have enabled 2FA on your Reddit account'>\n                    </div>\n                    <span class=\"form-text text-muted\"><a\n                            href=\"https://reddit-video-maker-bot.netlify.app/docs/configuring#setting-up-the-api\"\n                            target=\"_blank\">Need help? Click here to open step-by-step guide.</a></span>\n                </div>\n            </div>\n\n            <!-- Reddit Thread -->\n            <p class=\"h4\">Reddit Thread</p>\n            <div class=\"row mb-2\">\n                <label class=\"col-4\">Random Thread</label>\n                <div class=\"col-8\">\n                    <div class=\"form-check form-switch\">\n                        <input name=\"random\" class=\"form-check-input\" type=\"checkbox\" value=\"True\" data-toggle=\"tooltip\"\n                            data-original-title='If disabled, it will ask you for a thread link, instead of picking random one'>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"subreddit\" class=\"col-4\">Subreddit</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-reddit\"></i>\n                        </div>\n                        <input value=\"{{ data.subreddit }}\" name=\"subreddit\" type=\"text\" class=\"form-control\"\n                            placeholder=\"Subreddit to pull posts from (e.g. AskReddit)\" data-toggle=\"tooltip\"\n                            data-original-title='You can have multiple subreddits,\n                                    add \"+\" between them (e.g. AskReddit+Redditdev)'>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"post_id\" class=\"col-4\">Post ID</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-file-text\"></i>\n                        </div>\n                        <input value=\"{{ data.post_id }}\" name=\"post_id\" type=\"text\" class=\"form-control\"\n                            placeholder=\"Used if you want to use a specific post (e.g. urdtfx)\">\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"max_comment_length\" class=\"col-4\">Max Comment Length</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <input name=\"max_comment_length\" type=\"range\" class=\"form-range\" min=\"10\" max=\"10000\" step=\"1\"\n                            value=\"{{ data.max_comment_length }}\" data-toggle=\"tooltip\"\n                            data-original-title=\"{{ data.max_comment_length }}\">\n                    </div>\n                    <span class=\"form-text text-muted\">Max number of characters a comment can have.</span>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"post_lang\" class=\"col-4\">Post Language</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-translate\"></i>\n                        </div>\n                        <input value=\"{{ data.post_lang }}\" name=\"post_lang\" type=\"text\" class=\"form-control\"\n                            placeholder=\"The language you would like to translate to\">\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"min_comments\" class=\"col-4\">Minimum Comments</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <input name=\"min_comments\" type=\"range\" class=\"form-range\" min=\"15\" max=\"1000\" step=\"1\"\n                            value=\"{{ data.min_comments }}\" data-toggle=\"tooltip\"\n                            data-original-title=\"{{ data.min_comments }}\">\n                    </div>\n                    <span class=\"form-text text-muted\">The minimum number of comments a post should have to be\n                        included.</span>\n                </div>\n            </div>\n\n            <!-- General Settings -->\n            <p class=\"h4\">General Settings</p>\n            <div class=\"row mb-2\">\n                <label class=\"col-4\">Allow NSFW</label>\n                <div class=\"col-8\">\n                    <div class=\"form-check form-switch\">\n                        <input name=\"allow_nsfw\" class=\"form-check-input\" type=\"checkbox\" value=\"True\"\n                            data-toggle=\"tooltip\" data-original-title='If checked NSFW posts will be allowed'>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"theme\" class=\"col-4\">Reddit Theme</label>\n                <div class=\"col-8\">\n                    <select name=\"theme\" class=\"form-select\" data-toggle=\"tooltip\"\n                        data-original-title='Sets the theme of Reddit screenshots'>\n                        <option value=\"dark\">Dark</option>\n                        <option value=\"light\">Light</option>\n                    </select>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"times_to_run\" class=\"col-4\">Times To Run</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <input name=\"times_to_run\" type=\"range\" class=\"form-range\" min=\"1\" max=\"1000\" step=\"1\"\n                            value=\"{{ data.times_to_run }}\" data-toggle=\"tooltip\"\n                            data-original-title=\"{{ data.times_to_run }}\">\n                    </div>\n                    <span class=\"form-text text-muted\">Used if you want to create multiple videos.</span>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"opacity\" class=\"col-4\">Opacity Of Comments</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <input name=\"opacity\" type=\"range\" class=\"form-range\" min=\"0\" max=\"1\" step=\"0.05\"\n                            value=\"{{ data.opacity }}\" data-toggle=\"tooltip\" data-original-title=\"{{ data.opacity }}\">\n                    </div>\n                    <span class=\"form-text text-muted\">Sets the opacity of the comments when overlayed over the\n                        background.</span>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"transition\" class=\"col-4\">Transition</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <input name=\"transition\" type=\"range\" class=\"form-range\" min=\"0\" max=\"2\" step=\"0.05\"\n                            value=\"{{ data.transition }}\" data-toggle=\"tooltip\"\n                            data-original-title=\"{{ data.transition }}\">\n                    </div>\n                    <span class=\"form-text text-muted\">Sets the transition time (in seconds) between the\n                        comments. Set to 0 if you want to disable it.</span>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"background_choice\" class=\"col-4\">Background Choice</label>\n                <div class=\"col-8\">\n                    <select name=\"background_choice\" class=\"form-select\" data-toggle=\"tooltip\"\n                        data-original-title='Sets the background of the video'>\n                        <option value=\" \">Random Video</option>\n                        {% for background in checks[\"background_video\"][\"options\"][1:] %}\n                        <option value=\"{{background}}\">{{background}}</option>\n                        {% endfor %}\n                    </select>\n                    <span class=\"form-text text-muted\"><a href=\"/backgrounds\" target=\"_blank\">See all available\n                            backgrounds</a></span>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"background_thumbnail\" class=\"col-4\">Background Thumbnail</label>\n                <div class=\"col-8\">\n                    <div class=\"form-check form-switch\">\n                        <input name=\"background_thumbnail\" class=\"form-check-input\" type=\"checkbox\" value=\"True\"\n                            data-toggle=\"tooltip\"\n                            data-original-title='If checked a thumbnail will be added to the video (put a thumbnail.png file in the assets/backgrounds directory for it to be used.)'>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"background_thumbnail_font_family\" class=\"col-4\">Background Thumbnail Font Family (.ttf)</label>\n                <div class=\"col-8\">\n                    <input name=\"background_thumbnail_font_family\" type=\"text\" class=\"form-control\"\n                        placeholder=\"arial\" value=\"{{ data.background_thumbnail_font_family }}\">\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"background_thumbnail_font_size\" class=\"col-4\">Background Thumbnail Font Size (px)</label>\n                <div class=\"col-8\">\n                    <input name=\"background_thumbnail_font_size\" type=\"number\" class=\"form-control\"\n                        placeholder=\"96\" value=\"{{ data.background_thumbnail_font_size }}\">\n                </div>\n            </div>\n            <!-- need to create a color picker -->\n            <div class=\"row mb-2\">\n                <label for=\"background_thumbnail_font_color\" class=\"col-4\">Background Thumbnail Font Color (rgb)</label>\n                <div class=\"col-8\">\n                    <input name=\"background_thumbnail_font_color\" type=\"text\" class=\"form-control\"\n                        placeholder=\"255,255,255\" value=\"{{ data.background_thumbnail_font_color }}\">\n                </div>\n            </div>\n\n            <!-- TTS Settings -->\n            <p class=\"h4\">TTS Settings</p>\n            <div class=\"row mb-2\">\n                <label for=\"voice_choice\" class=\"col-4\">TTS Voice Choice</label>\n                <div class=\"col-8\">\n                    <select name=\"voice_choice\" class=\"form-select\" data-toggle=\"tooltip\"\n                        data-original-title='The voice platform used for TTS generation'>\n                        <option value=\"streamlabspolly\">Streamlabspolly</option>\n                        <option value=\"tiktok\">TikTok</option>\n                        <option value=\"googletranslate\">Google Translate</option>\n                        <option value=\"awspolly\">AWS Polly</option>\n                        <option value=\"pyttsx\">Python TTS (pyttsx)</option>\n                    </select>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"aws_polly_voice\" class=\"col-4\">AWS Polly Voice</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group voices\">\n                        <select name=\"aws_polly_voice\" class=\"form-select\" data-toggle=\"tooltip\"\n                            data-original-title='The voice used for AWS Polly'>\n                            <option value=\"Brian\">Brian</option>\n                            <option value=\"Emma\">Emma</option>\n                            <option value=\"Russell\">Russell</option>\n                            <option value=\"Joey\">Joey</option>\n                            <option value=\"Matthew\">Matthew</option>\n                            <option value=\"Joanna\">Joanna</option>\n                            <option value=\"Kimberly\">Kimberly</option>\n                            <option value=\"Amy\">Amy</option>\n                            <option value=\"Geraint\">Geraint</option>\n                            <option value=\"Nicole\">Nicole</option>\n                            <option value=\"Justin\">Justin</option>\n                            <option value=\"Ivy\">Ivy</option>\n                            <option value=\"Kendra\">Kendra</option>\n                            <option value=\"Salli\">Salli</option>\n                            <option value=\"Raveena\">Raveena</option>\n                        </select>\n\n                        <button type=\"button\" class=\"btn btn-primary\"><i id=\"awspolly_icon\"\n                                class=\"bi-volume-up-fill\"></i></button>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"streamlabs_polly_voice\" class=\"col-4\">Streamlabs Polly Voice</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group voices\">\n                        <select id=\"streamlabs_polly_voice\" name=\"streamlabs_polly_voice\" class=\"form-select\"\n                            data-toggle=\"tooltip\" data-original-title='The voice used for Streamlabs Polly'>\n                            <option value=\"Brian\">Brian</option>\n                            <option value=\"Emma\">Emma</option>\n                            <option value=\"Russell\">Russell</option>\n                            <option value=\"Joey\">Joey</option>\n                            <option value=\"Matthew\">Matthew</option>\n                            <option value=\"Joanna\">Joanna</option>\n                            <option value=\"Kimberly\">Kimberly</option>\n                            <option value=\"Amy\">Amy</option>\n                            <option value=\"Geraint\">Geraint</option>\n                            <option value=\"Nicole\">Nicole</option>\n                            <option value=\"Justin\">Justin</option>\n                            <option value=\"Ivy\">Ivy</option>\n                            <option value=\"Kendra\">Kendra</option>\n                            <option value=\"Salli\">Salli</option>\n                            <option value=\"Raveena\">Raveena</option>\n                        </select>\n\n                        <button type=\"button\" class=\"btn btn-primary\"><i id=\"streamlabs_icon\"\n                                class=\"bi bi-volume-up-fill\"></i></button>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"tiktok_voice\" class=\"col-4\">TikTok Voice</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group voices\">\n                        <select name=\"tiktok_voice\" class=\"form-select\" data-toggle=\"tooltip\"\n                            data-original-title='The voice used for TikTok TTS'>\n                            <option disabled value=\"\">-----Disney Voices-----</option>\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                            <option disabled value=\"\">-----English Voices-----</option>\n                            <option value=\"en_au_001\">English AU - Female</option>\n                            <option value=\"en_au_002\">English AU - Male</option>\n                            <option value=\"en_uk_001\">English UK - Male 1</option>\n                            <option value=\"en_uk_003\">English UK - Male 2</option>\n                            <option value=\"en_us_001\">English US - Female (Int. 1)</option>\n                            <option value=\"en_us_002\">English US - Female (Int. 2)</option>\n                            <option value=\"en_us_006\">English US - Male 1</option>\n                            <option value=\"en_us_007\">English US - Male 2</option>\n                            <option value=\"en_us_009\">English US - Male 3</option>\n                            <option value=\"en_us_010\">English US - Male 4</option>\n                            <option disabled value=\"\">-----European Voices-----</option>\n                            <option value=\"fr_001\">French - Male 1</option>\n                            <option value=\"fr_002\">French - Male 2</option>\n                            <option value=\"de_001\">German - Female</option>\n                            <option value=\"de_002\">German - Male</option>\n                            <option value=\"es_002\">Spanish - Male</option>\n                            <option disabled value=\"\">-----American Voices-----</option>\n                            <option value=\"es_mx_002\">Spanish MX - Male</option>\n                            <option value=\"br_001\">Portuguese BR - Female 1</option>\n                            <option value=\"br_003\">Portuguese BR - Female 2</option>\n                            <option value=\"br_004\">Portuguese BR - Female 3</option>\n                            <option value=\"br_005\">Portuguese BR - Male</option>\n                            <option disabled value=\"\">-----Asian Voices-----</option>\n                            <option value=\"id_001\">Indonesian - Female</option>\n                            <option value=\"jp_001\">Japanese - Female 1</option>\n                            <option value=\"jp_003\">Japanese - Female 2</option>\n                            <option value=\"jp_005\">Japanese - Female 3</option>\n                            <option value=\"jp_006\">Japanese - Male</option>\n                            <option value=\"kr_002\">Korean - Male 1</option>\n                            <option value=\"kr_003\">Korean - Female</option>\n                            <option value=\"kr_004\">Korean - Male 2</option>\n                        </select>\n\n                        <button type=\"button\" class=\"btn btn-primary\"><i id=\"tiktok_icon\"\n                                class=\"bi-volume-up-fill\"></i></button>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"tiktok_sessionid\" class=\"col-4\">TikTok SessionId</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-mic-fill\"></i>\n                        </div>\n                        <input value=\"{{ data.tiktok_sessionid }}\" name=\"tiktok_sessionid\" type=\"text\" class=\"form-control\"\n                            data-toggle=\"tooltip\"\n                            data-original-title=\"TikTok sessionid needed for the TTS API request. Check documentation if you don't know how to obtain it.\">\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"python_voice\" class=\"col-4\">Python Voice</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-mic-fill\"></i>\n                        </div>\n                        <input value=\"{{ data.python_voice }}\" name=\"python_voice\" type=\"text\" class=\"form-control\"\n                            data-toggle=\"tooltip\"\n                            data-original-title='The index of the system TTS voices (can be downloaded externally, run ptt.py to find value, start from zero)'>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"py_voice_num\" class=\"col-4\">Py Voice Number</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <div class=\"input-group-text\">\n                            <i class=\"bi bi-headset\"></i>\n                        </div>\n                        <input value=\"{{ data.py_voice_num }}\" name=\"py_voice_num\" type=\"text\" class=\"form-control\"\n                            data-toggle=\"tooltip\"\n                            data-original-title='The number of system voices (2 are pre-installed in Windows)'>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row mb-2\">\n                <label for=\"silence_duration\" class=\"col-4\">Silence Duration</label>\n                <div class=\"col-8\">\n                    <div class=\"input-group\">\n                        <input name=\"silence_duration\" type=\"range\" class=\"form-range\" min=\"0\" max=\"5\" step=\"0.05\"\n                            value=\"{{ data.silence_duration }}\" data-toggle=\"tooltip\"\n                            data-original-title=\"{{ data.silence_duration }}\">\n                    </div>\n                    <span class=\"form-text text-muted\">Time in seconds between TTS comments.</span>\n                </div>\n            </div>\n            <div class=\"col text-center\">\n                <br>\n                <button id=\"defaultSettingsBtn\" type=\"button\" class=\"btn btn-secondary\">Default\n                    Settings</button>\n                <button id=\"submitButton\" type=\"submit\" class=\"btn btn-success\">Save\n                    Changes</button>\n            </div>\n        </form>\n    </div>\n    <audio src=\"\"></audio>\n</main>\n\n<script>\n    // Test voices buttons\n    var playing = false;\n\n    $(\".voices button\").click(function () {\n        var icon = $(this).find(\"i\");\n        var audio = $(\"audio\");\n\n        if (playing) {\n            playing.toggleClass(\"bi-volume-up-fill bi-stop-fill\");\n\n            // Clicked the same button - stop audio\n            if (playing.prop(\"id\") == icon.prop(\"id\")) {\n                audio[0].pause();\n                playing = false;\n                return;\n            }\n        }\n\n        icon.toggleClass(\"bi-volume-up-fill bi-stop-fill\");\n        let path = \"voices/\" + $(this).closest(\".voices\").find(\"select\").prop(\"value\").toLowerCase() + \".mp3\";\n\n        audio.prop(\"src\", path);\n        audio[0].play();\n        playing = icon;\n\n        audio[0].onended = function () {\n            icon.toggleClass(\"bi-volume-up-fill bi-stop-fill\");\n            playing = false;\n        }\n    });\n\n    // Wait for DOM to load\n    $(document).ready(function () {\n        // Add tooltips\n        $('[data-toggle=\"tooltip\"]').tooltip();\n        $('[data-toggle=\"tooltip\"]').on('click', function () {\n            $(this).tooltip('hide');\n        });\n\n        // Update slider tooltip\n        $(\".form-range\").on(\"input\", function () {\n            $(this).attr(\"value\", $(this).val());\n            $(this).attr(\"data-original-title\", $(this).val());\n            $(this).tooltip(\"show\");\n        });\n\n        // Get current config\n        var data = JSON.parse('{{data | tojson}}');\n\n        // Set current checkboxes\n        $('.form-check-input').each(function () {\n            $(this).prop(\"checked\", data[$(this).prop(\"name\")]);\n        });\n\n        // Set current select options\n        $('.form-select').each(function () {\n            $(this).prop(\"value\", data[$(this).prop(\"name\")]);\n        });\n\n        // Submit \"False\" when checkbox isn't ticked\n        $('#settingsForm').submit(function () {\n            $('.form-check-input').each(function () {\n                if (!($(this).is(':checked'))) {\n                    $(this).prop(\"value\", \"False\");\n                    $(this).prop(\"checked\", true);\n                }\n            });\n        });\n\n\n        // Get validation values\n        let validateChecks = JSON.parse('{{checks | tojson}}');\n\n        // Set default values\n        $(\"#defaultSettingsBtn\").click(function (event) {\n            $(\"#settingsForm input, #settingsForm select\").each(function () {\n                let check = validateChecks[$(this).prop(\"name\")];\n\n                if (check[\"default\"]) {\n                    $(this).prop(\"value\", check[\"default\"]);\n\n                    // Update tooltip value for input[type=\"range\"]\n                    if ($(this).prop(\"type\") == \"range\") {\n                        $(this).attr(\"data-original-title\", check[\"default\"]);\n                    }\n                }\n            });\n        });\n\n        // Validate form\n        $('#settingsForm').submit(function (event) {\n            $(\"#settingsForm input, #settingsForm select\").each(function () {\n                if (!(validate($(this)))) {\n                    event.preventDefault();\n                    event.stopPropagation();\n\n                    $(\"html, body\").animate({\n                        scrollTop: $(this).offset().top\n                    });\n                }\n\n            });\n        });\n\n        $(\"#settingsForm input\").on(\"keyup\", function () {\n            validate($(this));\n        });\n\n        $(\"#settingsForm select\").on(\"change\", function () {\n            validate($(this));\n        });\n\n        function validate(object) {\n            let bool = check(object.prop(\"name\"), object.prop(\"value\"));\n\n            // Change class\n            if (bool) {\n                object.removeClass(\"is-invalid\");\n                object.addClass(\"is-valid\");\n            }\n            else {\n                object.removeClass(\"is-valid\");\n                object.addClass(\"is-invalid\");\n            }\n\n            return bool;\n\n            // Check values (return true/false)\n            function check(name, value) {\n                let check = validateChecks[name];\n\n                // If value is empty - check if it's optional\n                if (value.length == 0) {\n                    if (check[\"optional\"] == false) {\n                        return false;\n                    }\n                    else {\n                        object.prop(\"value\", check[\"default\"]);\n                        return true;\n                    }\n                }\n\n                // Check if value is too short\n                if (check[\"nmin\"]) {\n                    if (check[\"type\"] == \"int\" || check[\"type\"] == \"float\") {\n                        if (value < check[\"nmin\"]) {\n                            return false;\n                        }\n                    }\n                    else {\n                        if (value.length < check[\"nmin\"]) {\n                            return false;\n                        }\n\n                    }\n                }\n\n                // Check if value is too long\n                if (check[\"nmax\"]) {\n                    if (check[\"type\"] == \"int\" || check[\"type\"] == \"float\") {\n                        if (value > check[\"nmax\"]) {\n                            return false;\n                        }\n                    }\n                    else {\n                        if (value.length > check[\"nmax\"]) {\n                            return false;\n                        }\n\n                    }\n                }\n\n                // Check if value matches regex\n                if (check[\"regex\"]) {\n                    let regex = new RegExp(check[\"regex\"]);\n                    if (!(regex.test(value))) {\n                        return false;\n                    }\n                }\n\n                return true;\n            }\n        }\n    });\n</script>\n\n{% endblock %}\n"
  },
  {
    "path": "GUI.py",
    "content": "import webbrowser\r\nfrom pathlib import Path\r\n\r\n# Used \"tomlkit\" instead of \"toml\" because it doesn't change formatting on \"dump\"\r\nimport tomlkit\r\nfrom flask import (\r\n    Flask,\r\n    redirect,\r\n    render_template,\r\n    request,\r\n    send_from_directory,\r\n    url_for,\r\n)\r\n\r\nimport utils.gui_utils as gui\r\n\r\n# Set the hostname\r\nHOST = \"localhost\"\r\n# Set the port number\r\nPORT = 4000\r\n\r\n# Configure application\r\napp = Flask(__name__, template_folder=\"GUI\")\r\n\r\n# Configure secret key only to use 'flash'\r\napp.secret_key = b'_5#y2L\"F4Q8z\\n\\xec]/'\r\n\r\n\r\n# Ensure responses aren't cached\r\n@app.after_request\r\ndef after_request(response):\r\n    response.headers[\"Cache-Control\"] = \"no-cache, no-store, must-revalidate\"\r\n    response.headers[\"Expires\"] = 0\r\n    response.headers[\"Pragma\"] = \"no-cache\"\r\n    return response\r\n\r\n\r\n# Display index.html\r\n@app.route(\"/\")\r\ndef index():\r\n    return render_template(\"index.html\", file=\"videos.json\")\r\n\r\n\r\n@app.route(\"/backgrounds\", methods=[\"GET\"])\r\ndef backgrounds():\r\n    return render_template(\"backgrounds.html\", file=\"backgrounds.json\")\r\n\r\n\r\n@app.route(\"/background/add\", methods=[\"POST\"])\r\ndef background_add():\r\n    # Get form values\r\n    youtube_uri = request.form.get(\"youtube_uri\").strip()\r\n    filename = request.form.get(\"filename\").strip()\r\n    citation = request.form.get(\"citation\").strip()\r\n    position = request.form.get(\"position\").strip()\r\n\r\n    gui.add_background(youtube_uri, filename, citation, position)\r\n\r\n    return redirect(url_for(\"backgrounds\"))\r\n\r\n\r\n@app.route(\"/background/delete\", methods=[\"POST\"])\r\ndef background_delete():\r\n    key = request.form.get(\"background-key\")\r\n    gui.delete_background(key)\r\n\r\n    return redirect(url_for(\"backgrounds\"))\r\n\r\n\r\n@app.route(\"/settings\", methods=[\"GET\", \"POST\"])\r\ndef settings():\r\n    config_load = tomlkit.loads(Path(\"config.toml\").read_text())\r\n    config = gui.get_config(config_load)\r\n\r\n    # Get checks for all values\r\n    checks = gui.get_checks()\r\n\r\n    if request.method == \"POST\":\r\n        # Get data from form as dict\r\n        data = request.form.to_dict()\r\n\r\n        # Change settings\r\n        config = gui.modify_settings(data, config_load, checks)\r\n\r\n    return render_template(\"settings.html\", file=\"config.toml\", data=config, checks=checks)\r\n\r\n\r\n# Make videos.json accessible\r\n@app.route(\"/videos.json\")\r\ndef videos_json():\r\n    return send_from_directory(\"video_creation/data\", \"videos.json\")\r\n\r\n\r\n# Make backgrounds.json accessible\r\n@app.route(\"/backgrounds.json\")\r\ndef backgrounds_json():\r\n    return send_from_directory(\"utils\", \"backgrounds.json\")\r\n\r\n\r\n# Make videos in results folder accessible\r\n@app.route(\"/results/<path:name>\")\r\ndef results(name):\r\n    return send_from_directory(\"results\", name, as_attachment=True)\r\n\r\n\r\n# Make voices samples in voices folder accessible\r\n@app.route(\"/voices/<path:name>\")\r\ndef voices(name):\r\n    return send_from_directory(\"GUI/voices\", name, as_attachment=True)\r\n\r\n\r\n# Run browser and start the app\r\nif __name__ == \"__main__\":\r\n    webbrowser.open(f\"http://{HOST}:{PORT}\", new=2)\r\n    print(\"Website opened in new tab. Refresh if it didn't load.\")\r\n    app.run(port=PORT)\r\n"
  },
  {
    "path": "LICENSE",
    "content": "                 GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\nCopyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\nEveryone is permitted to copy and distribute verbatim copies\nof this license document, but changing it is not allowed.\n\n                            Preamble\n\nThe GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\nThe licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users. We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors. You can apply it to\nyour programs, too.\n\nWhen we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\nTo protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights. Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\nFor example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received. You must make sure that they, too, receive\nor can get the source code. And you must show them these terms so they\nknow their rights.\n\nDevelopers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\nFor the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software. For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\nSome devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so. This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software. The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable. Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts. If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\nFinally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary. To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\nThe precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n0. Definitions.\n\n\"This License\" refers to version 3 of the GNU General Public License.\n\n\"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n\"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\nTo \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy. The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\nA \"covered work\" means either the unmodified Program or a work based\non the Program.\n\nTo \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\nTo \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\nAn interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n1. Source Code.\n\nThe \"source code\" for a work means the preferred form of the work\nfor making modifications to it. \"Object code\" means any non-source\nform of a work.\n\nA \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\nThe \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\nThe \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\nThe Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\nThe Corresponding Source for a work in source code form is that\nsame work.\n\n2. Basic Permissions.\n\nAll rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\nYou may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force. You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright. Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\nConveying under any other circumstances is permitted solely under\nthe conditions stated below. Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\nNo covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\nWhen you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n4. Conveying Verbatim Copies.\n\nYou may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\nYou may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n5. Conveying Modified Source Versions.\n\nYou may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\nA compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n6. Conveying Non-Source Forms.\n\nYou may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\nA separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\nA \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling. In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage. For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product. A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n\"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source. The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\nIf you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\nThe requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed. Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\nCorresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n7. Additional Terms.\n\n\"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\nWhen you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\nNotwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\nAll other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\nIf you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\nAdditional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n8. Termination.\n\nYou may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\nHowever, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\nMoreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\nTermination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n9. Acceptance Not Required for Having Copies.\n\nYou are not required to accept this License in order to receive or\nrun a copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n10. Automatic Licensing of Downstream Recipients.\n\nEach time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\nAn \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\nYou may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n11. Patents.\n\nA \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\nA contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\nEach contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\nIn the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\nIf you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\nIf, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\nA patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License. You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\nNothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n12. No Surrender of Others' Freedom.\n\nIf conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all. For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n13. Use with the GNU Affero General Public License.\n\nNotwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n14. Revised Versions of this License.\n\nThe Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time. Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number. If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation. If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\nIf the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\nLater license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n15. Disclaimer of Warranty.\n\nTHERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n16. Limitation of Liability.\n\nIN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n17. Interpretation of Sections 15 and 16.\n\nIf the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\nIf you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\nTo do so, attach the following notices to the program. It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License. Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\nYou should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\nThe GNU General Public License does not permit incorporating your program\ninto proprietary programs. If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library. If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License. But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# Reddit Video Maker Bot 🎥\n\nAll done WITHOUT video editing or asset compiling. Just pure ✨programming magic✨.\n\nCreated by Lewis Menelaws & [TMRRW](https://tmrrwinc.ca)\n\n<a target=\"_blank\" href=\"https://tmrrwinc.ca\">\n<picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://user-images.githubusercontent.com/6053155/170528535-e274dc0b-7972-4b27-af22-637f8c370133.png\">\n  <source media=\"(prefers-color-scheme: light)\" srcset=\"https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png\">\n  <img src=\"https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png\" width=\"350\">\n</picture>\n\n</a>\n\n## Video Explainer\n\n[![lewisthumbnail](https://user-images.githubusercontent.com/6053155/173631669-1d1b14ad-c478-4010-b57d-d79592a789f2.png)\n](https://www.youtube.com/watch?v=3gjcY_00U1w)\n\n## Motivation 🤔\n\nThese videos on TikTok, YouTube and Instagram get MILLIONS of views across all platforms and require very little effort.\nThe only original thing being done is the editing and gathering of all materials...\n\n... but what if we can automate that process? 🤔\n\n## Disclaimers 🚨\n\n- **At the moment**, this repository won't attempt to upload this content through this bot. It will give you a file that\n  you will then have to upload manually. This is for the sake of avoiding any sort of community guideline issues.\n\n## Requirements\n\n- Python 3.10\n- Playwright (this should install automatically in installation)\n\n## Installation 👩‍💻\n\n1. Clone this repository:\n    ```sh\n    git clone https://github.com/elebumm/RedditVideoMakerBot.git\n    cd RedditVideoMakerBot\n    ```\n\n2. Create and activate a virtual environment:\n    - On **Windows**:\n        ```sh\n        python -m venv ./venv\n        .\\venv\\Scripts\\activate\n        ```\n    - On **macOS and Linux**:\n        ```sh\n        python3 -m venv ./venv\n        source ./venv/bin/activate\n        ```\n\n3. Install the required dependencies:\n    ```sh\n    pip install -r requirements.txt\n    ```\n\n4. Install Playwright and its dependencies:\n    ```sh\n    python -m playwright install\n    python -m playwright install-deps\n    ```\n\n---\n\n**EXPERIMENTAL!!!!**\n\n   - On macOS and Linux (Debian, Arch, Fedora, CentOS, and based on those), you can run an installation script that will automatically install steps 1 to 3. (requires bash)\n   - `bash <(curl -sL https://raw.githubusercontent.com/elebumm/RedditVideoMakerBot/master/install.sh)`\n   - This can also be used to update the installation\n\n---\n\n5. Run the bot:\n    ```sh\n    python main.py\n    ```\n\n6. Visit [the Reddit Apps page](https://www.reddit.com/prefs/apps), and set up an app that is a \"script\". Paste any URL in the redirect URL field, for example: `https://jasoncameron.dev`.\n\n7. The bot will prompt you to fill in your details to connect to the Reddit API and configure the bot to your liking.\n\n8. Enjoy 😎\n\n9. If you need to reconfigure the bot, simply open the `config.toml` file and delete the lines that need to be changed. On the next run of the bot, it will help you reconfigure those options.\n\n(Note: If you encounter any errors installing or running the bot, try using `python3` or `pip3` instead of `python` or `pip`.)\n\nFor a more detailed guide about the bot, please refer to the [documentation](https://reddit-video-maker-bot.netlify.app/).\n\n## Video\n\nhttps://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4\n\n## Contributing & Ways to improve 📈\n\nIn its current state, this bot does exactly what it needs to do. However, improvements can always be made!\n\nI have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute!\n\n- [ ] Creating better documentation and adding a command line interface.\n- [x] Allowing the user to choose background music for their videos.\n- [x] Allowing users to choose a reddit thread instead of being randomized.\n- [x] Allowing users to choose a background that is picked instead of the Minecraft one.\n- [x] Allowing users to choose between any subreddit.\n- [x] Allowing users to change voice.\n- [x] Checks if a video has already been created\n- [x] Light and Dark modes\n- [x] NSFW post filter\n\nPlease read our [contributing guidelines](CONTRIBUTING.md) for more detailed information.\n\n### For any questions or support join the [Discord](https://discord.gg/qfQSx45xCV) server\n\n## Developers and maintainers.\n\nElebumm (Lewis#6305) - https://github.com/elebumm (Founder)\n\nJason Cameron - https://github.com/JasonLovesDoggo (Maintainer)\n\nSimon (OpenSourceSimon) - https://github.com/OpenSourceSimon\n\nCallumIO (c.#6837) - https://github.com/CallumIO\n\nVerq (Verq#2338) - https://github.com/CordlessCoder\n\nLukaHietala (Pix.#0001) - https://github.com/LukaHietala\n\nFreebiell (Freebie#3263) - https://github.com/FreebieII\n\nAman Raza (electro199#8130) - https://github.com/electro199\n\nCyteon (cyteon) - https://github.com/cyteon\n\n\n## LICENSE\n[Roboto Fonts](https://fonts.google.com/specimen/Roboto/about) are licensed under [Apache License V2](https://www.apache.org/licenses/LICENSE-2.0)\n"
  },
  {
    "path": "TTS/GTTS.py",
    "content": "import random\n\nfrom gtts import gTTS\n\nfrom utils import settings\n\n\nclass GTTS:\n    def __init__(self):\n        self.max_chars = 5000\n        self.voices = []\n\n    def run(self, text, filepath, random_voice: bool = False):\n        tts = gTTS(\n            text=text,\n            lang=settings.config[\"reddit\"][\"thread\"][\"post_lang\"] or \"en\",\n            slow=False,\n        )\n        tts.save(filepath)\n\n    def randomvoice(self):\n        return random.choice(self.voices)\n"
  },
  {
    "path": "TTS/TikTok.py",
    "content": "# documentation for tiktok api: https://github.com/oscie57/tiktok-voice/wiki\nimport base64\nimport random\nimport time\nfrom typing import Final, Optional\n\nimport requests\n\nfrom utils import settings\n\n__all__ = [\"TikTok\", \"TikTokTTSException\"]\n\ndisney_voices: Final[tuple] = (\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    \"en_female_madam_leota\",  # Madame Leota\n    \"en_male_ghosthost\",  # Ghost Host\n    \"en_male_pirate\",  # pirate\n)\n\neng_voices: Final[tuple] = (\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    \"en_male_narration\",  # Narrator\n    \"en_male_funny\",  # Funny\n    \"en_female_emotional\",  # Peaceful\n    \"en_male_cody\",  # Serious\n)\n\nnon_eng_voices: Final[tuple] = (\n    # Western European 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    \"it_male_m18\",  # Italian - Male\n    # South american 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    # asian 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)\n\nvocals: Final[tuple] = (\n    \"en_female_f08_salut_damour\",  # Alto\n    \"en_male_m03_lobby\",  # Tenor\n    \"en_male_m03_sunshine_soon\",  # Sunshine Soon\n    \"en_female_f08_warmy_breeze\",  # Warmy Breeze\n    \"en_female_ht_f08_glorious\",  # Glorious\n    \"en_male_sing_funny_it_goes_up\",  # It Goes Up\n    \"en_male_m2_xhxs_m03_silly\",  # Chipmunk\n    \"en_female_ht_f08_wonderful_world\",  # Dramatic\n)\n\n\nclass TikTok:\n    \"\"\"TikTok Text-to-Speech Wrapper\"\"\"\n\n    def __init__(self):\n        headers = {\n            \"User-Agent\": \"com.zhiliaoapp.musically/2022600030 (Linux; U; Android 7.1.2; es_ES; SM-G988N; \"\n            \"Build/NRD90M;tt-ok/3.12.13.1)\",\n            \"Cookie\": f\"sessionid={settings.config['settings']['tts']['tiktok_sessionid']}\",\n        }\n\n        self.URI_BASE = \"https://api16-normal-c-useast1a.tiktokv.com/media/api/text/speech/invoke/\"\n        self.max_chars = 200\n\n        self._session = requests.Session()\n        # set the headers to the session, so we don't have to do it for every request\n        self._session.headers = headers\n\n    def run(self, text: str, filepath: str, random_voice: bool = False):\n        if random_voice:\n            voice = self.random_voice()\n        else:\n            # if tiktok_voice is not set in the config file, then use a random voice\n            voice = settings.config[\"settings\"][\"tts\"].get(\"tiktok_voice\", None)\n\n        # get the audio from the TikTok API\n        data = self.get_voices(voice=voice, text=text)\n\n        # check if there was an error in the request\n        status_code = data[\"status_code\"]\n        if status_code != 0:\n            raise TikTokTTSException(status_code, data[\"message\"])\n\n        # decode data from base64 to binary\n        try:\n            raw_voices = data[\"data\"][\"v_str\"]\n        except:\n            print(\n                \"The TikTok TTS returned an invalid response. Please try again later, and report this bug.\"\n            )\n            raise TikTokTTSException(0, \"Invalid response\")\n        decoded_voices = base64.b64decode(raw_voices)\n\n        # write voices to specified filepath\n        with open(filepath, \"wb\") as out:\n            out.write(decoded_voices)\n\n    def get_voices(self, text: str, voice: Optional[str] = None) -> dict:\n        \"\"\"If voice is not passed, the API will try to use the most fitting voice\"\"\"\n        # sanitize text\n        text = text.replace(\"+\", \"plus\").replace(\"&\", \"and\").replace(\"r/\", \"\")\n\n        # prepare url request\n        params = {\"req_text\": text, \"speaker_map_type\": 0, \"aid\": 1233}\n\n        if voice is not None:\n            params[\"text_speaker\"] = voice\n\n        # send request\n        try:\n            response = self._session.post(self.URI_BASE, params=params)\n        except ConnectionError:\n            time.sleep(random.randrange(1, 7))\n            response = self._session.post(self.URI_BASE, params=params)\n\n        return response.json()\n\n    @staticmethod\n    def random_voice() -> str:\n        return random.choice(eng_voices)\n\n\nclass TikTokTTSException(Exception):\n    def __init__(self, code: int, message: str):\n        self._code = code\n        self._message = message\n\n    def __str__(self) -> str:\n        if self._code == 1:\n            return f\"Code: {self._code}, reason: probably the aid value isn't correct, message: {self._message}\"\n\n        if self._code == 2:\n            return f\"Code: {self._code}, reason: the text is too long, message: {self._message}\"\n\n        if self._code == 4:\n            return f\"Code: {self._code}, reason: the speaker doesn't exist, message: {self._message}\"\n\n        return f\"Code: {self._message}, reason: unknown, message: {self._message}\"\n"
  },
  {
    "path": "TTS/__init__.py",
    "content": ""
  },
  {
    "path": "TTS/aws_polly.py",
    "content": "import random\nimport sys\n\nfrom boto3 import Session\nfrom botocore.exceptions import BotoCoreError, ClientError, ProfileNotFound\n\nfrom utils import settings\n\nvoices = [\n    \"Brian\",\n    \"Emma\",\n    \"Russell\",\n    \"Joey\",\n    \"Matthew\",\n    \"Joanna\",\n    \"Kimberly\",\n    \"Amy\",\n    \"Geraint\",\n    \"Nicole\",\n    \"Justin\",\n    \"Ivy\",\n    \"Kendra\",\n    \"Salli\",\n    \"Raveena\",\n]\n\n\nclass AWSPolly:\n    def __init__(self):\n        self.max_chars = 3000\n        self.voices = voices\n\n    def run(self, text, filepath, random_voice: bool = False):\n        try:\n            session = Session(profile_name=\"polly\")\n            polly = session.client(\"polly\")\n            if random_voice:\n                voice = self.randomvoice()\n            else:\n                if not settings.config[\"settings\"][\"tts\"][\"aws_polly_voice\"]:\n                    raise ValueError(\n                        f\"Please set the TOML variable AWS_VOICE to a valid voice. options are: {voices}\"\n                    )\n                voice = str(settings.config[\"settings\"][\"tts\"][\"aws_polly_voice\"]).capitalize()\n            try:\n                # Request speech synthesis\n                response = polly.synthesize_speech(\n                    Text=text, OutputFormat=\"mp3\", VoiceId=voice, Engine=\"neural\"\n                )\n            except (BotoCoreError, ClientError) as error:\n                # The service returned an error, exit gracefully\n                print(error)\n                sys.exit(-1)\n\n            # Access the audio stream from the response\n            if \"AudioStream\" in response:\n                file = open(filepath, \"wb\")\n                file.write(response[\"AudioStream\"].read())\n                file.close()\n                # print_substep(f\"Saved Text {idx} to MP3 files successfully.\", style=\"bold green\")\n\n            else:\n                # The response didn't contain audio data, exit gracefully\n                print(\"Could not stream audio\")\n                sys.exit(-1)\n        except ProfileNotFound:\n            print(\"You need to install the AWS CLI and configure your profile\")\n            print(\n                \"\"\"\n            Linux: https://docs.aws.amazon.com/polly/latest/dg/setup-aws-cli.html\n            Windows: https://docs.aws.amazon.com/polly/latest/dg/install-voice-plugin2.html\n            \"\"\"\n            )\n            sys.exit(-1)\n\n    def randomvoice(self):\n        return random.choice(self.voices)\n"
  },
  {
    "path": "TTS/elevenlabs.py",
    "content": "import random\n\nfrom elevenlabs import save\nfrom elevenlabs.client import ElevenLabs\n\nfrom utils import settings\n\n\nclass elevenlabs:\n    def __init__(self):\n        self.max_chars = 2500\n        self.client: ElevenLabs = None\n\n    def run(self, text, filepath, random_voice: bool = False):\n        if self.client is None:\n            self.initialize()\n        if random_voice:\n            voice = self.randomvoice()\n        else:\n            voice = str(settings.config[\"settings\"][\"tts\"][\"elevenlabs_voice_name\"]).capitalize()\n\n        audio = self.client.generate(text=text, voice=voice, model=\"eleven_multilingual_v1\")\n        save(audio=audio, filename=filepath)\n\n    def initialize(self):\n        if settings.config[\"settings\"][\"tts\"][\"elevenlabs_api_key\"]:\n            api_key = settings.config[\"settings\"][\"tts\"][\"elevenlabs_api_key\"]\n        else:\n            raise ValueError(\n                \"You didn't set an Elevenlabs API key! Please set the config variable ELEVENLABS_API_KEY to a valid API key.\"\n            )\n\n        self.client = ElevenLabs(api_key=api_key)\n\n    def randomvoice(self):\n        if self.client is None:\n            self.initialize()\n        return random.choice(self.client.voices.get_all().voices).name\n"
  },
  {
    "path": "TTS/engine_wrapper.py",
    "content": "import os\nimport re\nfrom pathlib import Path\nfrom typing import Tuple\n\nimport numpy as np\nimport translators\nfrom moviepy import AudioFileClip\nfrom moviepy.audio.AudioClip import AudioClip\nfrom moviepy.audio.fx import MultiplyVolume\nfrom rich.progress import track\n\nfrom utils import settings\nfrom utils.console import print_step, print_substep\nfrom utils.voice import sanitize_text\n\nDEFAULT_MAX_LENGTH: int = (\n    50  # Video length variable, edit this on your own risk. It should work, but it's not supported\n)\n\n\nclass TTSEngine:\n    \"\"\"Calls the given TTS engine to reduce code duplication and allow multiple TTS engines.\n\n    Args:\n        tts_module            : The TTS module. Your module should handle the TTS itself and saving to the given path under the run method.\n        reddit_object         : The reddit object that contains the posts to read.\n        path (Optional)       : The unix style path to save the mp3 files to. This must not have leading or trailing slashes.\n        max_length (Optional) : The maximum length of the mp3 files in total.\n\n    Notes:\n        tts_module must take the arguments text and filepath.\n    \"\"\"\n\n    def __init__(\n        self,\n        tts_module,\n        reddit_object: dict,\n        path: str = \"assets/temp/\",\n        max_length: int = DEFAULT_MAX_LENGTH,\n        last_clip_length: int = 0,\n    ):\n        self.tts_module = tts_module()\n        self.reddit_object = reddit_object\n\n        self.redditid = re.sub(r\"[^\\w\\s-]\", \"\", reddit_object[\"thread_id\"])\n        self.path = path + self.redditid + \"/mp3\"\n        self.max_length = max_length\n        self.length = 0\n        self.last_clip_length = last_clip_length\n\n    def add_periods(\n        self,\n    ):  # adds periods to the end of paragraphs (where people often forget to put them) so tts doesn't blend sentences\n        for comment in self.reddit_object[\"comments\"]:\n            # remove links\n            regex_urls = r\"((http|https)\\:\\/\\/)?[a-zA-Z0-9\\.\\/\\?\\:@\\-_=#]+\\.([a-zA-Z]){2,6}([a-zA-Z0-9\\.\\&\\/\\?\\:@\\-_=#])*\"\n            comment[\"comment_body\"] = re.sub(regex_urls, \" \", comment[\"comment_body\"])\n            comment[\"comment_body\"] = comment[\"comment_body\"].replace(\"\\n\", \". \")\n            comment[\"comment_body\"] = re.sub(r\"\\bAI\\b\", \"A.I\", comment[\"comment_body\"])\n            comment[\"comment_body\"] = re.sub(r\"\\bAGI\\b\", \"A.G.I\", comment[\"comment_body\"])\n            if comment[\"comment_body\"][-1] != \".\":\n                comment[\"comment_body\"] += \".\"\n            comment[\"comment_body\"] = comment[\"comment_body\"].replace(\". . .\", \".\")\n            comment[\"comment_body\"] = comment[\"comment_body\"].replace(\".. . \", \".\")\n            comment[\"comment_body\"] = comment[\"comment_body\"].replace(\". . \", \".\")\n            comment[\"comment_body\"] = re.sub(r'\\.\"\\.', '\".', comment[\"comment_body\"])\n\n    def run(self) -> Tuple[int, int]:\n        Path(self.path).mkdir(parents=True, exist_ok=True)\n        print_step(\"Saving Text to MP3 files...\")\n\n        self.add_periods()\n        self.call_tts(\"title\", process_text(self.reddit_object[\"thread_title\"]))\n        # processed_text = ##self.reddit_object[\"thread_post\"] != \"\"\n        idx = 0\n\n        if settings.config[\"settings\"][\"storymode\"]:\n            if settings.config[\"settings\"][\"storymodemethod\"] == 0:\n                if len(self.reddit_object[\"thread_post\"]) > self.tts_module.max_chars:\n                    self.split_post(self.reddit_object[\"thread_post\"], \"postaudio\")\n                else:\n                    self.call_tts(\"postaudio\", process_text(self.reddit_object[\"thread_post\"]))\n            elif settings.config[\"settings\"][\"storymodemethod\"] == 1:\n                for idx, text in track(enumerate(self.reddit_object[\"thread_post\"])):\n                    self.call_tts(f\"postaudio-{idx}\", process_text(text))\n\n        else:\n            for idx, comment in track(enumerate(self.reddit_object[\"comments\"]), \"Saving...\"):\n                # ! Stop creating mp3 files if the length is greater than max length.\n                if self.length > self.max_length and idx > 1:\n                    self.length -= self.last_clip_length\n                    idx -= 1\n                    break\n                if (\n                    len(comment[\"comment_body\"]) > self.tts_module.max_chars\n                ):  # Split the comment if it is too long\n                    self.split_post(comment[\"comment_body\"], idx)  # Split the comment\n                else:  # If the comment is not too long, just call the tts engine\n                    self.call_tts(f\"{idx}\", process_text(comment[\"comment_body\"]))\n\n        print_substep(\"Saved Text to MP3 files successfully.\", style=\"bold green\")\n        return self.length, idx\n\n    def split_post(self, text: str, idx):\n        split_files = []\n        split_text = [\n            x.group().strip()\n            for x in re.finditer(\n                r\" *(((.|\\n){0,\" + str(self.tts_module.max_chars) + \"})(\\.|.$))\", text\n            )\n        ]\n        self.create_silence_mp3()\n\n        for idy, text_cut in enumerate(split_text):\n            newtext = process_text(text_cut)\n            # print(f\"{idx}-{idy}: {newtext}\\n\")\n\n            if not newtext or newtext.isspace():\n                print(\"newtext was blank because sanitized split text resulted in none\")\n                continue\n            else:\n                self.call_tts(f\"{idx}-{idy}.part\", newtext)\n                with open(f\"{self.path}/list.txt\", \"w\") as f:\n                    for idz in range(0, len(split_text)):\n                        f.write(\"file \" + f\"'{idx}-{idz}.part.mp3'\" + \"\\n\")\n                    split_files.append(str(f\"{self.path}/{idx}-{idy}.part.mp3\"))\n                    f.write(\"file \" + f\"'silence.mp3'\" + \"\\n\")\n\n                os.system(\n                    \"ffmpeg -f concat -y -hide_banner -loglevel panic -safe 0 \"\n                    + \"-i \"\n                    + f\"{self.path}/list.txt \"\n                    + \"-c copy \"\n                    + f\"{self.path}/{idx}.mp3\"\n                )\n        try:\n            for i in range(0, len(split_files)):\n                os.unlink(split_files[i])\n        except FileNotFoundError as e:\n            print(\"File not found: \" + e.filename)\n        except OSError:\n            print(\"OSError\")\n\n    def call_tts(self, filename: str, text: str):\n        if settings.config[\"settings\"][\"tts\"][\"voice_choice\"] == \"googletranslate\":\n            # GTTS does not have the argument 'random_voice'\n            self.tts_module.run(\n                text,\n                filepath=f\"{self.path}/{filename}.mp3\",\n            )\n        else:\n            self.tts_module.run(\n                text,\n                filepath=f\"{self.path}/{filename}.mp3\",\n                random_voice=settings.config[\"settings\"][\"tts\"][\"random_voice\"],\n            )\n        # try:\n        #     self.length += MP3(f\"{self.path}/{filename}.mp3\").info.length\n        # except (MutagenError, HeaderNotFoundError):\n        #     self.length += sox.file_info.duration(f\"{self.path}/{filename}.mp3\")\n        try:\n            clip = AudioFileClip(f\"{self.path}/{filename}.mp3\")\n            self.last_clip_length = clip.duration\n            self.length += clip.duration\n            clip.close()\n        except:\n            self.length = 0\n\n    def create_silence_mp3(self):\n        silence_duration = settings.config[\"settings\"][\"tts\"][\"silence_duration\"]\n        silence = AudioClip(\n            frame_function=lambda t: np.sin(440 * 2 * np.pi * t),\n            duration=silence_duration,\n            fps=44100,\n        )\n        silence = silence.with_effects([MultiplyVolume(0)])\n        silence.write_audiofile(f\"{self.path}/silence.mp3\", fps=44100, logger=None)\n\n\ndef process_text(text: str, clean: bool = True):\n    lang = settings.config[\"reddit\"][\"thread\"][\"post_lang\"]\n    new_text = sanitize_text(text) if clean else text\n    if lang:\n        print_substep(\"Translating Text...\")\n        translated_text = translators.translate_text(text, translator=\"google\", to_language=lang)\n        new_text = sanitize_text(translated_text)\n    return new_text\n"
  },
  {
    "path": "TTS/openai_tts.py",
    "content": "import random\n\nimport requests\n\nfrom utils import settings\n\n\nclass OpenAITTS:\n    \"\"\"\n    A Text-to-Speech engine that uses an OpenAI-like TTS API endpoint to generate audio from text.\n\n    Attributes:\n        max_chars (int): Maximum number of characters allowed per API call.\n        api_key (str): API key loaded from settings.\n        api_url (str): The complete API endpoint URL, built from a base URL provided in the config.\n        available_voices (list): Static list of supported voices (according to current docs).\n    \"\"\"\n\n    def __init__(self):\n        # Set maximum input size based on API limits (4096 characters per request)\n        self.max_chars = 4096\n        self.api_key = settings.config[\"settings\"][\"tts\"].get(\"openai_api_key\")\n        if not self.api_key:\n            raise ValueError(\n                \"No OpenAI API key provided in settings! Please set 'openai_api_key' in your config.\"\n            )\n\n        # Read the base URL from the configuration (e.g., \"https://api.openai.com/v1\" or \"https://api.openai.com/v1/\")\n        base_url = settings.config[\"settings\"][\"tts\"].get(\n            \"openai_api_url\", \"https://api.openai.com/v1\"\n        )\n        # Remove trailing slash if present\n        if base_url.endswith(\"/\"):\n            base_url = base_url[:-1]\n        # Append the TTS-specific path\n        self.api_url = base_url + \"/audio/speech\"\n\n        # Set the available voices to a static list as per OpenAI TTS documentation.\n        self.available_voices = self.get_available_voices()\n\n    def get_available_voices(self):\n        \"\"\"\n        Return a static list of supported voices for the OpenAI TTS API.\n\n        According to the documentation, supported voices include:\n            \"alloy\", \"ash\", \"coral\", \"echo\", \"fable\", \"onyx\", \"nova\", \"sage\", \"shimmer\"\n        \"\"\"\n        return [\"alloy\", \"ash\", \"coral\", \"echo\", \"fable\", \"onyx\", \"nova\", \"sage\", \"shimmer\"]\n\n    def randomvoice(self):\n        \"\"\"\n        Select and return a random voice from the available voices.\n        \"\"\"\n        return random.choice(self.available_voices)\n\n    def run(self, text, filepath, random_voice: bool = False):\n        \"\"\"\n        Convert the provided text to speech and save the resulting audio to the specified filepath.\n\n        Args:\n            text (str): The input text to convert.\n            filepath (str): The file path where the generated audio will be saved.\n            random_voice (bool): If True, select a random voice from the available voices.\n        \"\"\"\n        # Choose voice based on configuration or randomly if requested.\n        if random_voice:\n            voice = self.randomvoice()\n        else:\n            voice = settings.config[\"settings\"][\"tts\"].get(\"openai_voice_name\", \"alloy\")\n            voice = str(voice).lower()  # Ensure lower-case as expected by the API\n\n        # Select the model from configuration; default to 'tts-1'\n        model = settings.config[\"settings\"][\"tts\"].get(\"openai_model\", \"tts-1\")\n\n        # Create payload for API request\n        payload = {\n            \"model\": model,\n            \"voice\": voice,\n            \"input\": text,\n            \"response_format\": \"mp3\",  # allowed formats: \"mp3\", \"aac\", \"opus\", \"flac\", \"pcm\" or \"wav\"\n        }\n        headers = {\"Authorization\": f\"Bearer {self.api_key}\", \"Content-Type\": \"application/json\"}\n        try:\n            response = requests.post(self.api_url, headers=headers, json=payload)\n            if response.status_code != 200:\n                raise RuntimeError(f\"Error from TTS API: {response.status_code} {response.text}\")\n            # Write response as binary into file.\n            with open(filepath, \"wb\") as f:\n                f.write(response.content)\n        except Exception as e:\n            raise RuntimeError(f\"Failed to generate audio with OpenAI TTS API: {str(e)}\")\n"
  },
  {
    "path": "TTS/pyttsx.py",
    "content": "import random\n\nimport pyttsx3\n\nfrom utils import settings\n\n\nclass pyttsx:\n    def __init__(self):\n        self.max_chars = 5000\n        self.voices = []\n\n    def run(\n        self,\n        text: str,\n        filepath: str,\n        random_voice=False,\n    ):\n        voice_id = settings.config[\"settings\"][\"tts\"][\"python_voice\"]\n        voice_num = settings.config[\"settings\"][\"tts\"][\"py_voice_num\"]\n        if voice_id == \"\" or voice_num == \"\":\n            voice_id = 2\n            voice_num = 3\n            raise ValueError(\"set pyttsx values to a valid value, switching to defaults\")\n        else:\n            voice_id = int(voice_id)\n            voice_num = int(voice_num)\n        for i in range(voice_num):\n            self.voices.append(i)\n            i = +1\n        if random_voice:\n            voice_id = self.randomvoice()\n        engine = pyttsx3.init()\n        voices = engine.getProperty(\"voices\")\n        engine.setProperty(\n            \"voice\", voices[voice_id].id\n        )  # changing index changes voices but ony 0 and 1 are working here\n        engine.save_to_file(text, f\"{filepath}\")\n        engine.runAndWait()\n\n    def randomvoice(self):\n        return random.choice(self.voices)\n"
  },
  {
    "path": "TTS/streamlabs_polly.py",
    "content": "import random\n\nimport requests\nfrom requests.exceptions import JSONDecodeError\n\nfrom utils import settings\nfrom utils.voice import check_ratelimit\n\nvoices = [\n    \"Brian\",\n    \"Emma\",\n    \"Russell\",\n    \"Joey\",\n    \"Matthew\",\n    \"Joanna\",\n    \"Kimberly\",\n    \"Amy\",\n    \"Geraint\",\n    \"Nicole\",\n    \"Justin\",\n    \"Ivy\",\n    \"Kendra\",\n    \"Salli\",\n    \"Raveena\",\n]\n\n\n# valid voices https://lazypy.ro/tts/\n\n\nclass StreamlabsPolly:\n    def __init__(self):\n        self.url = \"https://streamlabs.com/polly/speak\"\n        self.max_chars = 550\n        self.voices = voices\n\n    def run(self, text, filepath, random_voice: bool = False):\n        if random_voice:\n            voice = self.randomvoice()\n        else:\n            if not settings.config[\"settings\"][\"tts\"][\"streamlabs_polly_voice\"]:\n                raise ValueError(\n                    f\"Please set the config variable STREAMLABS_POLLY_VOICE to a valid voice. options are: {voices}\"\n                )\n            voice = str(settings.config[\"settings\"][\"tts\"][\"streamlabs_polly_voice\"]).capitalize()\n\n        body = {\"voice\": voice, \"text\": text, \"service\": \"polly\"}\n        headers = {\"Referer\": \"https://streamlabs.com/\"}\n        response = requests.post(self.url, headers=headers, data=body)\n\n        if not check_ratelimit(response):\n            self.run(text, filepath, random_voice)\n\n        else:\n            try:\n                voice_data = requests.get(response.json()[\"speak_url\"])\n                with open(filepath, \"wb\") as f:\n                    f.write(voice_data.content)\n            except (KeyError, JSONDecodeError):\n                try:\n                    if response.json()[\"error\"] == \"No text specified!\":\n                        raise ValueError(\"Please specify a text to convert to speech.\")\n                except (KeyError, JSONDecodeError):\n                    print(\"Error occurred calling Streamlabs Polly\")\n\n    def randomvoice(self):\n        return random.choice(self.voices)\n"
  },
  {
    "path": "build.sh",
    "content": "#!/bin/sh\ndocker build -t rvmt .\n"
  },
  {
    "path": "fonts/LICENSE.txt",
    "content": "\r\n                                 Apache License\r\n                           Version 2.0, January 2004\r\n                        http://www.apache.org/licenses/\r\n\r\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\r\n\r\n   1. Definitions.\r\n\r\n      \"License\" shall mean the terms and conditions for use, reproduction,\r\n      and distribution as defined by Sections 1 through 9 of this document.\r\n\r\n      \"Licensor\" shall mean the copyright owner or entity authorized by\r\n      the copyright owner that is granting the License.\r\n\r\n      \"Legal Entity\" shall mean the union of the acting entity and all\r\n      other entities that control, are controlled by, or are under common\r\n      control with that entity. For the purposes of this definition,\r\n      \"control\" means (i) the power, direct or indirect, to cause the\r\n      direction or management of such entity, whether by contract or\r\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\r\n      outstanding shares, or (iii) beneficial ownership of such entity.\r\n\r\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\r\n      exercising permissions granted by this License.\r\n\r\n      \"Source\" form shall mean the preferred form for making modifications,\r\n      including but not limited to software source code, documentation\r\n      source, and configuration files.\r\n\r\n      \"Object\" form shall mean any form resulting from mechanical\r\n      transformation or translation of a Source form, including but\r\n      not limited to compiled object code, generated documentation,\r\n      and conversions to other media types.\r\n\r\n      \"Work\" shall mean the work of authorship, whether in Source or\r\n      Object form, made available under the License, as indicated by a\r\n      copyright notice that is included in or attached to the work\r\n      (an example is provided in the Appendix below).\r\n\r\n      \"Derivative Works\" shall mean any work, whether in Source or Object\r\n      form, that is based on (or derived from) the Work and for which the\r\n      editorial revisions, annotations, elaborations, or other modifications\r\n      represent, as a whole, an original work of authorship. For the purposes\r\n      of this License, Derivative Works shall not include works that remain\r\n      separable from, or merely link (or bind by name) to the interfaces of,\r\n      the Work and Derivative Works thereof.\r\n\r\n      \"Contribution\" shall mean any work of authorship, including\r\n      the original version of the Work and any modifications or additions\r\n      to that Work or Derivative Works thereof, that is intentionally\r\n      submitted to Licensor for inclusion in the Work by the copyright owner\r\n      or by an individual or Legal Entity authorized to submit on behalf of\r\n      the copyright owner. For the purposes of this definition, \"submitted\"\r\n      means any form of electronic, verbal, or written communication sent\r\n      to the Licensor or its representatives, including but not limited to\r\n      communication on electronic mailing lists, source code control systems,\r\n      and issue tracking systems that are managed by, or on behalf of, the\r\n      Licensor for the purpose of discussing and improving the Work, but\r\n      excluding communication that is conspicuously marked or otherwise\r\n      designated in writing by the copyright owner as \"Not a Contribution.\"\r\n\r\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\r\n      on behalf of whom a Contribution has been received by Licensor and\r\n      subsequently incorporated within the Work.\r\n\r\n   2. Grant of Copyright License. Subject to the terms and conditions of\r\n      this License, each Contributor hereby grants to You a perpetual,\r\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\r\n      copyright license to reproduce, prepare Derivative Works of,\r\n      publicly display, publicly perform, sublicense, and distribute the\r\n      Work and such Derivative Works in Source or Object form.\r\n\r\n   3. Grant of Patent License. Subject to the terms and conditions of\r\n      this License, each Contributor hereby grants to You a perpetual,\r\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\r\n      (except as stated in this section) patent license to make, have made,\r\n      use, offer to sell, sell, import, and otherwise transfer the Work,\r\n      where such license applies only to those patent claims licensable\r\n      by such Contributor that are necessarily infringed by their\r\n      Contribution(s) alone or by combination of their Contribution(s)\r\n      with the Work to which such Contribution(s) was submitted. If You\r\n      institute patent litigation against any entity (including a\r\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\r\n      or a Contribution incorporated within the Work constitutes direct\r\n      or contributory patent infringement, then any patent licenses\r\n      granted to You under this License for that Work shall terminate\r\n      as of the date such litigation is filed.\r\n\r\n   4. Redistribution. You may reproduce and distribute copies of the\r\n      Work or Derivative Works thereof in any medium, with or without\r\n      modifications, and in Source or Object form, provided that You\r\n      meet the following conditions:\r\n\r\n      (a) You must give any other recipients of the Work or\r\n          Derivative Works a copy of this License; and\r\n\r\n      (b) You must cause any modified files to carry prominent notices\r\n          stating that You changed the files; and\r\n\r\n      (c) You must retain, in the Source form of any Derivative Works\r\n          that You distribute, all copyright, patent, trademark, and\r\n          attribution notices from the Source form of the Work,\r\n          excluding those notices that do not pertain to any part of\r\n          the Derivative Works; and\r\n\r\n      (d) If the Work includes a \"NOTICE\" text file as part of its\r\n          distribution, then any Derivative Works that You distribute must\r\n          include a readable copy of the attribution notices contained\r\n          within such NOTICE file, excluding those notices that do not\r\n          pertain to any part of the Derivative Works, in at least one\r\n          of the following places: within a NOTICE text file distributed\r\n          as part of the Derivative Works; within the Source form or\r\n          documentation, if provided along with the Derivative Works; or,\r\n          within a display generated by the Derivative Works, if and\r\n          wherever such third-party notices normally appear. The contents\r\n          of the NOTICE file are for informational purposes only and\r\n          do not modify the License. You may add Your own attribution\r\n          notices within Derivative Works that You distribute, alongside\r\n          or as an addendum to the NOTICE text from the Work, provided\r\n          that such additional attribution notices cannot be construed\r\n          as modifying the License.\r\n\r\n      You may add Your own copyright statement to Your modifications and\r\n      may provide additional or different license terms and conditions\r\n      for use, reproduction, or distribution of Your modifications, or\r\n      for any such Derivative Works as a whole, provided Your use,\r\n      reproduction, and distribution of the Work otherwise complies with\r\n      the conditions stated in this License.\r\n\r\n   5. Submission of Contributions. Unless You explicitly state otherwise,\r\n      any Contribution intentionally submitted for inclusion in the Work\r\n      by You to the Licensor shall be under the terms and conditions of\r\n      this License, without any additional terms or conditions.\r\n      Notwithstanding the above, nothing herein shall supersede or modify\r\n      the terms of any separate license agreement you may have executed\r\n      with Licensor regarding such Contributions.\r\n\r\n   6. Trademarks. This License does not grant permission to use the trade\r\n      names, trademarks, service marks, or product names of the Licensor,\r\n      except as required for reasonable and customary use in describing the\r\n      origin of the Work and reproducing the content of the NOTICE file.\r\n\r\n   7. Disclaimer of Warranty. Unless required by applicable law or\r\n      agreed to in writing, Licensor provides the Work (and each\r\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\r\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\r\n      implied, including, without limitation, any warranties or conditions\r\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\r\n      PARTICULAR PURPOSE. You are solely responsible for determining the\r\n      appropriateness of using or redistributing the Work and assume any\r\n      risks associated with Your exercise of permissions under this License.\r\n\r\n   8. Limitation of Liability. In no event and under no legal theory,\r\n      whether in tort (including negligence), contract, or otherwise,\r\n      unless required by applicable law (such as deliberate and grossly\r\n      negligent acts) or agreed to in writing, shall any Contributor be\r\n      liable to You for damages, including any direct, indirect, special,\r\n      incidental, or consequential damages of any character arising as a\r\n      result of this License or out of the use or inability to use the\r\n      Work (including but not limited to damages for loss of goodwill,\r\n      work stoppage, computer failure or malfunction, or any and all\r\n      other commercial damages or losses), even if such Contributor\r\n      has been advised of the possibility of such damages.\r\n\r\n   9. Accepting Warranty or Additional Liability. While redistributing\r\n      the Work or Derivative Works thereof, You may choose to offer,\r\n      and charge a fee for, acceptance of support, warranty, indemnity,\r\n      or other liability obligations and/or rights consistent with this\r\n      License. However, in accepting such obligations, You may act only\r\n      on Your own behalf and on Your sole responsibility, not on behalf\r\n      of any other Contributor, and only if You agree to indemnify,\r\n      defend, and hold each Contributor harmless for any liability\r\n      incurred by, or claims asserted against, such Contributor by reason\r\n      of your accepting any such warranty or additional liability.\r\n\r\n   END OF TERMS AND CONDITIONS\r\n\r\n   APPENDIX: How to apply the Apache License to your work.\r\n\r\n      To apply the Apache License to your work, attach the following\r\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\r\n      replaced with your own identifying information. (Don't include\r\n      the brackets!)  The text should be enclosed in the appropriate\r\n      comment syntax for the file format. We also recommend that a\r\n      file or class name and description of purpose be included on the\r\n      same \"printed page\" as the copyright notice for easier\r\n      identification within third-party archives.\r\n\r\n   Copyright [yyyy] [name of copyright owner]\r\n\r\n   Licensed under the Apache License, Version 2.0 (the \"License\");\r\n   you may not use this file except in compliance with the License.\r\n   You may obtain a copy of the License at\r\n\r\n       http://www.apache.org/licenses/LICENSE-2.0\r\n\r\n   Unless required by applicable law or agreed to in writing, software\r\n   distributed under the License is distributed on an \"AS IS\" BASIS,\r\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n   See the License for the specific language governing permissions and\r\n   limitations under the License.\r\n"
  },
  {
    "path": "install.sh",
    "content": "#!/bin/bash \n\n# If the install fails, then print an error and exit.\nfunction install_fail() {\n    echo \"Installation failed\" \n    exit 1 \n} \n\n# This is the help fuction. It helps users withe the options\nfunction Help(){ \n    echo \"Usage: install.sh [option]\" \n    echo \"Options:\" \n    echo \"  -h: Show this help message and exit\" \n    echo \"  -d: Install only dependencies\" \n    echo \"  -p: Install only python dependencies (including playwright)\" \n    echo \"  -b: Install just the bot\"\n    echo \"  -l: Install the bot and the python dependencies\"\n} \n\n# Options\nwhile getopts \":hydpbl\" option; do\n    case $option in\n        # -h, prints help message\n        h)\n            Help exit 0;;\n        # -y, assumes yes\n        y)\n            ASSUME_YES=1;;\n        # -d install only dependencies\n        d)\n            DEPS_ONLY=1;;\n        # -p install only python dependencies\n        p)\n            PYTHON_ONLY=1;;\n        b)\n            JUST_BOT=1;;\n        l)\n            BOT_AND_PYTHON=1;;\n        # if a bad argument is given, then throw an error\n        \\?)\n            echo \"Invalid option: -$OPTARG\" >&2 Help exit 1;;\n        :)\n            echo \"Option -$OPTARG requires an argument.\" >&2 Help exit 1;;\n    esac\ndone \n\n# Install dependencies for MacOS\nfunction install_macos(){\n    # Check if homebrew is installed\n    if [ ! command -v brew &> /dev/null ]; then\n        echo \"Installing Homebrew\"\n        # if it's is not installed, then install it in a NONINTERACTIVE way\n        NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)\" \n        # Check for what arcitecture, so you can place path.\n        if [[ \"uname -m\" == \"x86_64\" ]]; then\n            echo \"export PATH=/usr/local/bin:$PATH\" >> ~/.bash_profile && source ~/.bash_profile\n        fi\n    # If not\n    else\n        # Print that it's already installed\n        echo \"Homebrew is already installed\"\n    fi\n    # Install the required packages\n    echo \"Installing required Packages\" \n    if [! command --version python3 &> /dev/null ]; then\n\t    echo \"Installing python3\"\n\t    brew install python@3.10\n    else\n\t    echo \"python3 already installed.\"\n    fi\n    brew install tcl-tk python-tk\n} \n\n# Function to install for arch (and other forks like manjaro)\nfunction install_arch(){ \n    echo \"Installing required packages\"\n    sudo pacman -S --needed python3 tk git && python3 -m ensurepip unzip || install_fail\n} \n\n# Function to install for debian (and ubuntu)\nfunction install_deb(){ \n    echo \"Installing required packages\"\n    sudo apt install python3 python3-dev python3-tk python3-pip unzip || install_fail\n} \n\n# Function to install for fedora (and other forks)\nfunction install_fedora(){ \n    echo \"Installing required packages\" \n    sudo dnf install python3 python3-tkinter python3-pip python3-devel unzip || install_fail\n} \n\n# Function to install for centos (and other forks based on it)\nfunction install_centos(){\n    echo \"Installing required packages\"\n    sudo yum install -y python3 || install_fail\n    sudo yum install -y python3-tkinter epel-release python3-pip unzip|| install_fail\n} \n\nfunction get_the_bot(){ \n    echo \"Downloading the bot\" \n    rm -rf RedditVideoMakerBot-master\n    curl -sL https://github.com/elebumm/RedditVideoMakerBot/archive/refs/heads/master.zip -o master.zip\n    unzip master.zip\n    rm -rf master.zip\n} \n\n#install python dependencies\nfunction install_python_dep(){ \n    # tell the user that the script is going to install the python dependencies\n    echo \"Installing python dependencies\" \n    # cd into the directory\n    cd RedditVideoMakerBot-master\n    # install the dependencies\n    pip3 install -r requirements.txt \n    # cd out\n    cd ..\n} \n\n# install playwright function\nfunction install_playwright(){\n    # tell the user that the script is going to install playwright \n    echo \"Installing playwright\"\n    # cd into the directory where the script is downloaded\n    cd RedditVideoMakerBot-master\n    # run the install script\n    python3 -m playwright install \n    python3 -m playwright install-deps \n    # give a note\n    printf \"Note, if these gave any errors, playwright may not be officially supported on your OS, check this issues page for support\\nhttps://github.com/microsoft/playwright/issues\"\n    if [ -x \"$(command -v pacman)\" ]; then\n        printf \"It seems you are on and Arch based distro.\\nTry installing these from the AUR for playwright to run:\\nenchant1.6\\nicu66\\nlibwebp052\\n\"\n    fi\n    cd ..\n} \n\n# Install depndencies\nfunction install_deps(){ \n    # if the platform is mac, install macos\n    if [ \"$(uname)\" == \"Darwin\" ]; then\n        install_macos || install_fail\n    # if pacman is found\n    elif [ -x \"$(command -v pacman)\" ]; then\n        # install for arch\n        install_arch || install_fail\n    # if apt-get is found\n    elif [ -x \"$(command -v apt-get)\" ]; then\n        # install fro debian\n        install_deb || install_fail\n    # if dnf is found\n    elif [ -x \"$(command -v dnf)\" ]; then\n        # install for fedora\n        install_fedora || install_fail\n    # if yum is found\n    elif [ -x \"$(command -v yum)\" ]; then\n        # install for centos\n        install_centos || install_fail\n    # else\n    else\n        # print an error message and exit\n        printf \"Your OS is not supported\\n Please install python3, pip3 and git manually\\n After that, run the script again with the -pb option to install python and playwright dependencies\\n If you want to add support for your OS, please open a pull request on github\\n\nhttps://github.com/elebumm/RedditVideoMakerBot\"\n        exit 1\n    fi\n}\n\n# Main function\nfunction install_main(){ \n    # Print that are installing\n    echo \"Installing...\" \n    # if -y (assume yes) continue \n    if [[ ASSUME_YES -eq 1 ]]; then\n        echo \"Assuming yes\"\n    # else, ask if they want to continue\n    else\n        echo \"Continue? (y/n)\" \n        read answer \n        # if the answer is not yes, then exit\n        if [ \"$answer\" != \"y\" ]; then\n            echo \"Aborting\" \n            exit 1\n        fi\n    fi \n    # if the -d (only dependencies) options is selected install just the dependencies\n    if [[ DEPS_ONLY -eq 1 ]]; then\n        echo \"Installing only dependencies\" \n        install_deps\n    elif [[ PYTHON_ONLY -eq 1 ]]; then\n    # if the -p (only python dependencies) options is selected install just the python dependencies and playwright\n        echo \"Installing only python dependencies\" \n        install_python_dep \n        install_playwright\n    # if the -b (only the bot) options is selected install just the bot\n    elif [[ JUST_BOT -eq 1 ]]; then\n        echo \"Installing only the bot\"\n        get_the_bot\n    # if the -l (bot and python) options is selected install just the bot and python dependencies\n    elif [[ BOT_AND_PYTHON -eq 1 ]]; then\n        echo \"Installing only the bot and python dependencies\"\n        get_the_bot\n        install_python_dep\n    # else, install everything\n    else\n        echo \"Installing all\" \n        install_deps \n        get_the_bot \n        install_python_dep\n        install_playwright\n    fi\n\n    DIR=\"./RedditVideoMakerBot-master\"\n    if [ -d \"$DIR\" ]; then\n        printf \"\\nThe bot is installed, want to run it?\"\n        # if -y (assume yes) continue \n        if [[ ASSUME_YES -eq 1 ]]; then\n            echo \"Assuming yes\"\n            # else, ask if they want to continue\n        else\n            echo \"Continue? (y/n)\" \n            read answer \n            # if the answer is not yes, then exit\n            if [ \"$answer\" != \"y\" ]; then\n                echo \"Aborting\" \n                exit 1\n            fi\n        fi\n        cd RedditVideoMakerBot-master\n        python3 main.py\n    fi\n}\n\n# Run the main function\ninstall_main\n"
  },
  {
    "path": "main.py",
    "content": "#!/usr/bin/env python\nimport math\nimport sys\nfrom os import name\nfrom pathlib import Path\nfrom subprocess import Popen\nfrom typing import Dict, NoReturn\n\nfrom prawcore import ResponseException\n\nfrom reddit.subreddit import get_subreddit_threads\nfrom utils import settings\nfrom utils.cleanup import cleanup\nfrom utils.console import print_markdown, print_step, print_substep\nfrom utils.ffmpeg_install import ffmpeg_install\nfrom utils.id import extract_id\nfrom utils.version import checkversion\nfrom video_creation.background import (\n    chop_background,\n    download_background_audio,\n    download_background_video,\n    get_background_config,\n)\nfrom video_creation.final_video import make_final_video\nfrom video_creation.screenshot_downloader import get_screenshots_of_reddit_posts\nfrom video_creation.voices import save_text_to_mp3\n\n__VERSION__ = \"3.4.0\"\n\nprint(\n    \"\"\"\n██████╗ ███████╗██████╗ ██████╗ ██╗████████╗    ██╗   ██╗██╗██████╗ ███████╗ ██████╗     ███╗   ███╗ █████╗ ██╗  ██╗███████╗██████╗\n██╔══██╗██╔════╝██╔══██╗██╔══██╗██║╚══██╔══╝    ██║   ██║██║██╔══██╗██╔════╝██╔═══██╗    ████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗\n██████╔╝█████╗  ██║  ██║██║  ██║██║   ██║       ██║   ██║██║██║  ██║█████╗  ██║   ██║    ██╔████╔██║███████║█████╔╝ █████╗  ██████╔╝\n██╔══██╗██╔══╝  ██║  ██║██║  ██║██║   ██║       ╚██╗ ██╔╝██║██║  ██║██╔══╝  ██║   ██║    ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝  ██╔══██╗\n██║  ██║███████╗██████╔╝██████╔╝██║   ██║        ╚████╔╝ ██║██████╔╝███████╗╚██████╔╝    ██║ ╚═╝ ██║██║  ██║██║  ██╗███████╗██║  ██║\n╚═╝  ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚═╝   ╚═╝         ╚═══╝  ╚═╝╚═════╝ ╚══════╝ ╚═════╝     ╚═╝     ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝\n\"\"\"\n)\nprint_markdown(\n    \"### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/\"\n)\ncheckversion(__VERSION__)\n\nreddit_id: str\nreddit_object: Dict[str, str | list]\n\n\ndef main(POST_ID=None) -> None:\n    global reddit_id, reddit_object\n    reddit_object = get_subreddit_threads(POST_ID)\n    reddit_id = extract_id(reddit_object)\n    print_substep(f\"Thread ID is {reddit_id}\", style=\"bold blue\")\n    length, number_of_comments = save_text_to_mp3(reddit_object)\n    length = math.ceil(length)\n    get_screenshots_of_reddit_posts(reddit_object, number_of_comments)\n    bg_config = {\n        \"video\": get_background_config(\"video\"),\n        \"audio\": get_background_config(\"audio\"),\n    }\n    download_background_video(bg_config[\"video\"])\n    download_background_audio(bg_config[\"audio\"])\n    chop_background(bg_config, length, reddit_object)\n    make_final_video(number_of_comments, length, reddit_object, bg_config)\n\n\ndef run_many(times) -> None:\n    for x in range(1, times + 1):\n        print_step(\n            f'on the {x}{(\"th\", \"st\", \"nd\", \"rd\", \"th\", \"th\", \"th\", \"th\", \"th\", \"th\")[x % 10]} iteration of {times}'\n        )\n        main()\n        Popen(\"cls\" if name == \"nt\" else \"clear\", shell=True).wait()\n\n\ndef shutdown() -> NoReturn:\n    if \"reddit_id\" in globals():\n        print_markdown(\"## Clearing temp files\")\n        cleanup(reddit_id)\n\n    print(\"Exiting...\")\n    sys.exit()\n\n\nif __name__ == \"__main__\":\n    if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]:\n        print(\n            \"Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again.\"\n        )\n        sys.exit()\n    ffmpeg_install()\n    directory = Path().absolute()\n    config = settings.check_toml(\n        f\"{directory}/utils/.config.template.toml\", f\"{directory}/config.toml\"\n    )\n    config is False and sys.exit()\n\n    if (\n        not settings.config[\"settings\"][\"tts\"][\"tiktok_sessionid\"]\n        or settings.config[\"settings\"][\"tts\"][\"tiktok_sessionid\"] == \"\"\n    ) and config[\"settings\"][\"tts\"][\"voice_choice\"] == \"tiktok\":\n        print_substep(\n            \"TikTok voice requires a sessionid! Check our documentation on how to obtain one.\",\n            \"bold red\",\n        )\n        sys.exit()\n    try:\n        if config[\"reddit\"][\"thread\"][\"post_id\"]:\n            for index, post_id in enumerate(config[\"reddit\"][\"thread\"][\"post_id\"].split(\"+\")):\n                index += 1\n                print_step(\n                    f'on the {index}{(\"st\" if index % 10 == 1 else (\"nd\" if index % 10 == 2 else (\"rd\" if index % 10 == 3 else \"th\")))} post of {len(config[\"reddit\"][\"thread\"][\"post_id\"].split(\"+\"))}'\n                )\n                main(post_id)\n                Popen(\"cls\" if name == \"nt\" else \"clear\", shell=True).wait()\n        elif config[\"settings\"][\"times_to_run\"]:\n            run_many(config[\"settings\"][\"times_to_run\"])\n        else:\n            main()\n    except KeyboardInterrupt:\n        shutdown()\n    except ResponseException:\n        print_markdown(\"## Invalid credentials\")\n        print_markdown(\"Please check your credentials in the config.toml file\")\n        shutdown()\n    except Exception as err:\n        config[\"settings\"][\"tts\"][\"tiktok_sessionid\"] = \"REDACTED\"\n        config[\"settings\"][\"tts\"][\"elevenlabs_api_key\"] = \"REDACTED\"\n        config[\"settings\"][\"tts\"][\"openai_api_key\"] = \"REDACTED\"\n        print_step(\n            f\"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\\n\"\n            f\"Version: {__VERSION__} \\n\"\n            f\"Error: {err} \\n\"\n            f'Config: {config[\"settings\"]}'\n        )\n        raise err\n"
  },
  {
    "path": "ptt.py",
    "content": "import pyttsx3\n\nengine = pyttsx3.init()\nvoices = engine.getProperty(\"voices\")\nfor voice in voices:\n    print(voice, voice.id)\n    engine.setProperty(\"voice\", voice.id)\n    engine.say(\"Hello World!\")\n    engine.runAndWait()\n    engine.stop()\n"
  },
  {
    "path": "reddit/subreddit.py",
    "content": "import re\n\nimport praw\nfrom praw.models import MoreComments\nfrom prawcore.exceptions import ResponseException\n\nfrom utils import settings\nfrom utils.ai_methods import sort_by_similarity\nfrom utils.console import print_step, print_substep\nfrom utils.posttextparser import posttextparser\nfrom utils.subreddit import _contains_blocked_words, get_subreddit_undone\nfrom utils.videos import check_done\nfrom utils.voice import sanitize_text\n\n\ndef get_subreddit_threads(POST_ID: str):\n    \"\"\"\n    Returns a list of threads from the AskReddit subreddit.\n    \"\"\"\n\n    print_substep(\"Logging into Reddit.\")\n\n    content = {}\n    if settings.config[\"reddit\"][\"creds\"][\"2fa\"]:\n        print(\"\\nEnter your two-factor authentication code from your authenticator app.\\n\")\n        code = input(\"> \")\n        print()\n        pw = settings.config[\"reddit\"][\"creds\"][\"password\"]\n        passkey = f\"{pw}:{code}\"\n    else:\n        passkey = settings.config[\"reddit\"][\"creds\"][\"password\"]\n    username = settings.config[\"reddit\"][\"creds\"][\"username\"]\n    if str(username).casefold().startswith(\"u/\"):\n        username = username[2:]\n    try:\n        reddit = praw.Reddit(\n            client_id=settings.config[\"reddit\"][\"creds\"][\"client_id\"],\n            client_secret=settings.config[\"reddit\"][\"creds\"][\"client_secret\"],\n            user_agent=\"Accessing Reddit threads\",\n            username=username,\n            passkey=passkey,\n            check_for_async=False,\n        )\n    except ResponseException as e:\n        if e.response.status_code == 401:\n            print(\"Invalid credentials - please check them in config.toml\")\n    except:\n        print(\"Something went wrong...\")\n\n    # Ask user for subreddit input\n    print_step(\"Getting subreddit threads...\")\n    similarity_score = 0\n    if not settings.config[\"reddit\"][\"thread\"][\n        \"subreddit\"\n    ]:  # note to user. you can have multiple subreddits via reddit.subreddit(\"redditdev+learnpython\")\n        try:\n            subreddit = reddit.subreddit(\n                re.sub(r\"r\\/\", \"\", input(\"What subreddit would you like to pull from? \"))\n                # removes the r/ from the input\n            )\n        except ValueError:\n            subreddit = reddit.subreddit(\"askreddit\")\n            print_substep(\"Subreddit not defined. Using AskReddit.\")\n    else:\n        sub = settings.config[\"reddit\"][\"thread\"][\"subreddit\"]\n        print_substep(f\"Using subreddit: r/{sub} from TOML config\")\n        subreddit_choice = sub\n        if str(subreddit_choice).casefold().startswith(\"r/\"):  # removes the r/ from the input\n            subreddit_choice = subreddit_choice[2:]\n        subreddit = reddit.subreddit(subreddit_choice)\n\n    if POST_ID:  # would only be called if there are multiple queued posts\n        submission = reddit.submission(id=POST_ID)\n\n    elif (\n        settings.config[\"reddit\"][\"thread\"][\"post_id\"]\n        and len(str(settings.config[\"reddit\"][\"thread\"][\"post_id\"]).split(\"+\")) == 1\n    ):\n        submission = reddit.submission(id=settings.config[\"reddit\"][\"thread\"][\"post_id\"])\n    elif settings.config[\"ai\"][\"ai_similarity_enabled\"]:  # ai sorting based on comparison\n        threads = subreddit.hot(limit=50)\n        keywords = settings.config[\"ai\"][\"ai_similarity_keywords\"].split(\",\")\n        keywords = [keyword.strip() for keyword in keywords]\n        # Reformat the keywords for printing\n        keywords_print = \", \".join(keywords)\n        print(f\"Sorting threads by similarity to the given keywords: {keywords_print}\")\n        threads, similarity_scores = sort_by_similarity(threads, keywords)\n        submission, similarity_score = get_subreddit_undone(\n            threads, subreddit, similarity_scores=similarity_scores\n        )\n    else:\n        threads = subreddit.hot(limit=25)\n        submission = get_subreddit_undone(threads, subreddit)\n\n    if submission is None:\n        return get_subreddit_threads(POST_ID)  # submission already done. rerun\n\n    elif not submission.num_comments and settings.config[\"settings\"][\"storymode\"] == \"false\":\n        print_substep(\"No comments found. Skipping.\")\n        exit()\n\n    submission = check_done(submission)  # double-checking\n\n    upvotes = submission.score\n    ratio = submission.upvote_ratio * 100\n    num_comments = submission.num_comments\n    threadurl = f\"https://new.reddit.com/{submission.permalink}\"\n\n    print_substep(f\"Video will be: {submission.title} :thumbsup:\", style=\"bold green\")\n    print_substep(f\"Thread url is: {threadurl} :thumbsup:\", style=\"bold green\")\n    print_substep(f\"Thread has {upvotes} upvotes\", style=\"bold blue\")\n    print_substep(f\"Thread has a upvote ratio of {ratio}%\", style=\"bold blue\")\n    print_substep(f\"Thread has {num_comments} comments\", style=\"bold blue\")\n    if similarity_score:\n        print_substep(\n            f\"Thread has a similarity score up to {round(similarity_score * 100)}%\",\n            style=\"bold blue\",\n        )\n\n    content[\"thread_url\"] = threadurl\n    content[\"thread_title\"] = submission.title\n    content[\"thread_id\"] = submission.id\n    content[\"is_nsfw\"] = submission.over_18\n    content[\"comments\"] = []\n    if settings.config[\"settings\"][\"storymode\"]:\n        if settings.config[\"settings\"][\"storymodemethod\"] == 1:\n            content[\"thread_post\"] = posttextparser(submission.selftext)\n        else:\n            content[\"thread_post\"] = submission.selftext\n    else:\n        for top_level_comment in submission.comments:\n            if isinstance(top_level_comment, MoreComments):\n                continue\n\n            if top_level_comment.body in [\"[removed]\", \"[deleted]\"]:\n                continue  # # see https://github.com/JasonLovesDoggo/RedditVideoMakerBot/issues/78\n            if _contains_blocked_words(top_level_comment.body):\n                continue\n            if not top_level_comment.stickied:\n                sanitised = sanitize_text(top_level_comment.body)\n                if not sanitised or sanitised == \" \":\n                    continue\n                if len(top_level_comment.body) <= int(\n                    settings.config[\"reddit\"][\"thread\"][\"max_comment_length\"]\n                ):\n                    if len(top_level_comment.body) >= int(\n                        settings.config[\"reddit\"][\"thread\"][\"min_comment_length\"]\n                    ):\n                        if (\n                            top_level_comment.author is not None\n                            and sanitize_text(top_level_comment.body) is not None\n                        ):  # if errors occur with this change to if not.\n                            content[\"comments\"].append(\n                                {\n                                    \"comment_body\": top_level_comment.body,\n                                    \"comment_url\": top_level_comment.permalink,\n                                    \"comment_id\": top_level_comment.id,\n                                }\n                            )\n\n    print_substep(\"Received subreddit threads Successfully.\", style=\"bold green\")\n    return content\n"
  },
  {
    "path": "requirements.txt",
    "content": "boto3==1.36.8\nbotocore==1.36.8\ngTTS==2.5.4\nmoviepy==2.2.1\nplaywright==1.49.1\npraw==7.8.1\nrequests==2.32.3\nrich==13.9.4\ntoml==0.10.2\ntranslators==5.9.9\npyttsx3==2.98\ntomlkit==0.13.2\nFlask==3.1.1\nclean-text==0.6.0\nunidecode==1.4.0\nspacy==3.8.7\ntorch==2.7.0\ntransformers==4.52.4\nffmpeg-python==0.2.0\nelevenlabs==1.57.0\nyt-dlp==2025.10.22\n"
  },
  {
    "path": "run.bat",
    "content": "@echo off\nset VENV_DIR=.venv\n\nif exist \"%VENV_DIR%\" (\n    echo Activating virtual environment...\n    call \"%VENV_DIR%\\Scripts\\activate.bat\"\n)\n\necho Running Python script...\npython main.py\n\nif errorlevel 1 (\n    echo An error occurred. Press any key to exit.\n    pause >nul\n)\n"
  },
  {
    "path": "run.sh",
    "content": "#!/bin/sh\ndocker run -v $(pwd)/out/:/app/assets -v $(pwd)/.env:/app/.env -it rvmt\n"
  },
  {
    "path": "utils/.config.template.toml",
    "content": "[reddit.creds]\nclient_id = { optional = false, nmin = 12, nmax = 30, explanation = \"The ID of your Reddit app of SCRIPT type\", example = \"fFAGRNJru1FTz70BzhT3Zg\", regex = \"^[-a-zA-Z0-9._~+/]+=*$\", input_error = \"The client ID can only contain printable characters.\", oob_error = \"The ID should be over 12 and under 30 characters, double check your input.\" }\nclient_secret = { optional = false, nmin = 20, nmax = 40, explanation = \"The SECRET of your Reddit app of SCRIPT type\", example = \"fFAGRNJru1FTz70BzhT3Zg\", regex = \"^[-a-zA-Z0-9._~+/]+=*$\", input_error = \"The client ID can only contain printable characters.\", oob_error = \"The secret should be over 20 and under 40 characters, double check your input.\" }\nusername = { optional = false, nmin = 3, nmax = 20, explanation = \"The username of your reddit account\", example = \"JasonLovesDoggo\", regex = \"^[-_0-9a-zA-Z]+$\", oob_error = \"A username HAS to be between 3 and 20 characters\" }\npassword = { optional = false, nmin = 8, explanation = \"The password of your reddit account\", example = \"fFAGRNJru1FTz70BzhT3Zg\", oob_error = \"Password too short\" }\n2fa = { optional = true, type = \"bool\", options = [true, false, ], default = false, explanation = \"Whether you have Reddit 2FA enabled, Valid options are True and False\", example = true }\n\n\n[reddit.thread]\nrandom = { optional = true, options = [true, false, ], default = false, type = \"bool\", explanation = \"If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'\", example = \"True\" }\nsubreddit = { optional = false, regex = \"[_0-9a-zA-Z\\\\+]+$\", nmin = 3, explanation = \"What subreddit to pull posts from, the name of the sub, not the URL. You can have multiple subreddits, add an + with no spaces.\", example = \"AskReddit+Redditdev\", oob_error = \"A subreddit name HAS to be between 3 and 20 characters\" }\npost_id = { optional = true, default = \"\", regex = \"^((?!://|://)[+a-zA-Z0-9])*$\", explanation = \"Used if you want to use a specific post.\", example = \"urdtfx\" }\nmax_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = \"int\", explanation = \"max number of characters a comment can have. default is 500\", example = 500, oob_error = \"the max comment length should be between 10 and 10000\" }\nmin_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = \"int\", explanation = \"min_comment_length number of characters a comment can have. default is 0\", example = 50, oob_error = \"the max comment length should be between 1 and 100\" }\npost_lang = { default = \"\", optional = true, explanation = \"The language you would like to translate to.\", example = \"es-cr\", options = ['','af', 'ak', 'am', 'ar', 'as', 'ay', 'az', 'be', 'bg', 'bho', 'bm', 'bn', 'bs', 'ca', 'ceb', 'ckb', 'co', 'cs', 'cy', 'da', 'de', 'doi', 'dv', 'ee', 'el', 'en', 'en-US', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gom', 'gu', 'ha', 'haw', 'hi', 'hmn', 'hr', 'ht', 'hu', 'hy', 'id', 'ig', 'ilo', 'is', 'it', 'iw', 'ja', 'jw', 'ka', 'kk', 'km', 'kn', 'ko', 'kri', 'ku', 'ky', 'la', 'lb', 'lg', 'ln', 'lo', 'lt', 'lus', 'lv', 'mai', 'mg', 'mi', 'mk', 'ml', 'mn', 'mni-Mtei', 'mr', 'ms', 'mt', 'my', 'ne', 'nl', 'no', 'nso', 'ny', 'om', 'or', 'pa', 'pl', 'ps', 'pt', 'qu', 'ro', 'ru', 'rw', 'sa', 'sd', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vi', 'xh', 'yi', 'yo', 'zh-CN', 'zh-TW', 'zu'] }\nmin_comments = { default = 20, optional = false, nmin = 10, type = \"int\", explanation = \"The minimum number of comments a post should have to be included. default is 20\", example = 29, oob_error = \"the minimum number of comments should be between 15 and 999999\" }\nblocked_words = { optional = true, default = \"\", type = \"str\", explanation = \"Comma-separated list of words/phrases. Posts and comments containing any of these will be skipped.\", example = \"nsfw, spoiler, politics\" }\n\n[ai]\nai_similarity_enabled = {optional = true, option = [true, false], default = false, type = \"bool\", explanation = \"Threads read from Reddit are sorted based on their similarity to the keywords given below\"}\nai_similarity_keywords = {optional = true, type=\"str\", example= 'Elon Musk, Twitter, Stocks', explanation = \"Every keyword or even sentence, seperated with comma, is used to sort the reddit threads based on similarity\"}\n\n[settings]\nallow_nsfw = { optional = false, type = \"bool\", default = false, example = false, options = [true, false, ], explanation = \"Whether to allow NSFW content, True or False\" }\ntheme = { optional = false, default = \"dark\", example = \"light\", options = [\"dark\", \"light\", \"transparent\", ], explanation = \"Sets the Reddit theme, either LIGHT or DARK. For story mode you can also use a transparent background.\" }\ntimes_to_run = { optional = false, default = 1, example = 2, explanation = \"Used if you want to run multiple times. Set to an int e.g. 4 or 29 or 1\", type = \"int\", nmin = 1, oob_error = \"It's very hard to run something less than once.\" }\nopacity = { optional = false, default = 0.9, example = 0.8, explanation = \"Sets the opacity of the comments when overlayed over the background\", type = \"float\", nmin = 0, nmax = 1, oob_error = \"The opacity HAS to be between 0 and 1\", input_error = \"The opacity HAS to be a decimal number between 0 and 1\" }\n#transition = { optional = true, default = 0.2, example = 0.2, explanation = \"Sets the transition time (in seconds) between the comments. Set to 0 if you want to disable it.\", type = \"float\", nmin = 0, nmax = 2, oob_error = \"The transition HAS to be between 0 and 2\", input_error = \"The opacity HAS to be a decimal number between 0 and 2\" }\nstorymode = { optional = true, type = \"bool\", default = false, example = false, options = [true, false,], explanation = \"Only read out title and post content, great for subreddits with stories\" }\nstorymodemethod= { optional = true, default = 1, example = 1, explanation = \"Style that's used for the storymode. Set to 0 for single picture display in whole video, set to 1 for fancy looking video \", type = \"int\", nmin = 0, oob_error = \"It's very hard to run something less than once.\", options = [0, 1] }\nstorymode_max_length = { optional = true, default = 1000, example = 1000, explanation = \"Max length of the storymode video in characters. 200 characters are approximately 50 seconds.\", type = \"int\", nmin = 1, oob_error = \"It's very hard to make a video under a second.\" }\nresolution_w = { optional = false, default = 1080, example = 1440, explantation = \"Sets the width in pixels of the final video\" }\nresolution_h = { optional = false, default = 1920, example = 2560, explantation = \"Sets the height in pixels of the final video\" }\nzoom = { optional = true, default = 1, example = 1.1, explanation = \"Sets the browser zoom level. Useful if you want the text larger.\", type = \"float\", nmin = 0.1, nmax = 2, oob_error = \"The text is really difficult to read at a zoom level higher than 2\" }\nchannel_name = { optional = true, default = \"Reddit Tales\", example = \"Reddit Stories\", explanation = \"Sets the channel name for the video\" }\n\n[settings.background]\nbackground_video = { optional = true, default = \"minecraft\", example = \"rocket-league\", options = [\"minecraft\", \"gta\", \"rocket-league\", \"motor-gta\", \"csgo-surf\", \"cluster-truck\", \"minecraft-2\",\"multiversus\",\"fall-guys\",\"steep\", \"\"], explanation = \"Sets the background for the video based on game name\" }\nbackground_audio = { optional = true, default = \"lofi\", example = \"chill-summer\", options = [\"lofi\",\"lofi-2\",\"chill-summer\",\"\"], explanation = \"Sets the background audio for the video\" }\nbackground_audio_volume = { optional = true, type = \"float\", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation=\"Sets the volume of the background audio. If you don't want background audio, set it to 0.\", oob_error = \"The volume HAS to be between 0 and 1\", input_error = \"The volume HAS to be a float number between 0 and 1\"}\nenable_extra_audio = { optional = true, type = \"bool\", default = false, example = false, explanation=\"Used if you want to render another video without background audio in a separate folder\", input_error = \"The value HAS to be true or false\"}\nbackground_thumbnail = { optional = true, type = \"bool\", default = false, example = false, options = [true, false,], explanation = \"Generate a thumbnail for the video (put a thumbnail.png file in the assets/backgrounds directory.)\" }\nbackground_thumbnail_font_family = { optional = true, default = \"arial\", example = \"arial\", explanation = \"Font family for the thumbnail text\" }\nbackground_thumbnail_font_size = { optional = true, type = \"int\", default = 96, example = 96, explanation = \"Font size in pixels for the thumbnail text\" }\nbackground_thumbnail_font_color = { optional = true, default = \"255,255,255\", example = \"255,255,255\", explanation = \"Font color in RGB format for the thumbnail text\" }\n\n[settings.tts]\nvoice_choice = { optional = false, default = \"tiktok\", options = [\"elevenlabs\", \"streamlabspolly\", \"tiktok\", \"googletranslate\", \"awspolly\", \"pyttsx\", \"OpenAI\"], example = \"tiktok\", explanation = \"The voice platform used for TTS generation. \" }\nrandom_voice = { optional = false, type = \"bool\", default = true, example = true, options = [true, false,], explanation = \"Randomizes the voice used for each comment\" }\nelevenlabs_voice_name = { optional = false, default = \"Bella\", example = \"Bella\", explanation = \"The voice used for elevenlabs\", options = [\"Adam\", \"Antoni\", \"Arnold\", \"Bella\", \"Domi\", \"Elli\", \"Josh\", \"Rachel\", \"Sam\", ] }\nelevenlabs_api_key = { optional = true, example = \"21f13f91f54d741e2ae27d2ab1b99d59\", explanation = \"Elevenlabs API key\" }\naws_polly_voice = { optional = false, default = \"Matthew\", example = \"Matthew\", explanation = \"The voice used for AWS Polly\" }\nstreamlabs_polly_voice = { optional = false, default = \"Matthew\", example = \"Matthew\", explanation = \"The voice used for Streamlabs Polly\" }\ntiktok_voice = { optional = true, default = \"en_us_001\", example = \"en_us_006\", explanation = \"The voice used for TikTok TTS\" }\ntiktok_sessionid = { optional = true, example = \"c76bcc3a7625abcc27b508c7db457ff1\", explanation = \"TikTok sessionid needed if you're using the TikTok TTS. Check documentation if you don't know how to obtain it.\" }\npython_voice = { optional = false, default = \"1\", example = \"1\", explanation = \"The index of the system tts voices (can be downloaded externally, run ptt.py to find value, start from zero)\" }\npy_voice_num = { optional = false, default = \"2\", example = \"2\", explanation = \"The number of system voices (2 are pre-installed in Windows)\" }\nsilence_duration = { optional = true, example = \"0.1\", explanation = \"Time in seconds between TTS comments\", default = 0.3, type = \"float\" }\nno_emojis = { optional = false, type = \"bool\", default = false, example = false, options = [true, false,], explanation = \"Whether to remove emojis from the comments\" }\nopenai_api_url = { optional = true, default = \"https://api.openai.com/v1/\", example = \"https://api.openai.com/v1/\", explanation = \"The API endpoint URL for OpenAI TTS generation\" }\nopenai_api_key = { optional = true, example = \"sk-abc123def456...\", explanation = \"Your OpenAI API key for TTS generation\" }\nopenai_voice_name = { optional = false, default = \"alloy\", example = \"alloy\", explanation = \"The voice used for OpenAI TTS generation\", options = [\"alloy\", \"ash\", \"coral\", \"echo\", \"fable\", \"onyx\", \"nova\", \"sage\", \"shimmer\", \"af_heart\"] }\nopenai_model = { optional = false, default = \"tts-1\", example = \"tts-1\", explanation = \"The model variant used for OpenAI TTS generation\", options = [\"tts-1\", \"tts-1-hd\", \"gpt-4o-mini-tts\"] }\n"
  },
  {
    "path": "utils/__init__.py",
    "content": ""
  },
  {
    "path": "utils/ai_methods.py",
    "content": "import numpy as np\nimport torch\nfrom transformers import AutoModel, AutoTokenizer\n\n\n# Mean Pooling - Take attention mask into account for correct averaging\ndef mean_pooling(model_output, attention_mask):\n    token_embeddings = model_output[0]  # First element of model_output contains all token embeddings\n    input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()\n    return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(\n        input_mask_expanded.sum(1), min=1e-9\n    )\n\n\n# This function sorts the given threads based on their total similarity with the given keywords\ndef sort_by_similarity(thread_objects, keywords):\n    # Initialize tokenizer + model.\n    tokenizer = AutoTokenizer.from_pretrained(\"sentence-transformers/all-MiniLM-L6-v2\")\n    model = AutoModel.from_pretrained(\"sentence-transformers/all-MiniLM-L6-v2\")\n\n    # Transform the generator to a list of Submission Objects, so we can sort later based on context similarity to\n    # keywords\n    thread_objects = list(thread_objects)\n\n    threads_sentences = []\n    for i, thread in enumerate(thread_objects):\n        threads_sentences.append(\" \".join([thread.title, thread.selftext]))\n\n    # Threads inference\n    encoded_threads = tokenizer(\n        threads_sentences, padding=True, truncation=True, return_tensors=\"pt\"\n    )\n    with torch.no_grad():\n        threads_embeddings = model(**encoded_threads)\n    threads_embeddings = mean_pooling(threads_embeddings, encoded_threads[\"attention_mask\"])\n\n    # Keyword inference\n    encoded_keywords = tokenizer(keywords, padding=True, truncation=True, return_tensors=\"pt\")\n    with torch.no_grad():\n        keywords_embeddings = model(**encoded_keywords)\n    keywords_embeddings = mean_pooling(keywords_embeddings, encoded_keywords[\"attention_mask\"])\n\n    # Compare every keyword w/ every thread embedding\n    threads_embeddings_tensor = torch.tensor(threads_embeddings)\n    total_scores = torch.zeros(threads_embeddings_tensor.shape[0])\n    cosine_similarity = torch.nn.CosineSimilarity()\n    for keyword_embedding in keywords_embeddings:\n        keyword_embedding = torch.tensor(keyword_embedding).repeat(\n            threads_embeddings_tensor.shape[0], 1\n        )\n        similarity = cosine_similarity(keyword_embedding, threads_embeddings_tensor)\n        total_scores += similarity\n\n    similarity_scores, indices = torch.sort(total_scores, descending=True)\n\n    # threads_sentences = np.array(threads_sentences)[indices.numpy()]\n\n    thread_objects = np.array(thread_objects)[indices.numpy()].tolist()\n\n    # print('Similarity Thread Ranking')\n    # for i, thread in enumerate(thread_objects):\n    #    print(f'{i}) {threads_sentences[i]} score {similarity_scores[i]}')\n\n    return thread_objects, similarity_scores\n"
  },
  {
    "path": "utils/background_audios.json",
    "content": "{\n    \"__comment\": \"Supported Backgrounds Audio. Can add/remove background audio here...\",\n    \"lofi\": [\n        \"https://www.youtube.com/watch?v=LTphVIore3A\",\n        \"lofi.mp3\",\n        \"Super Lofi World\"\n    ],\n    \"lofi-2\":[\n        \"https://www.youtube.com/watch?v=BEXL80LS0-I\",\n        \"lofi-2.mp3\",\n        \"stompsPlaylist\"\n    ],\n    \"chill-summer\":[\n        \"https://www.youtube.com/watch?v=EZE8JagnBI8\",\n        \"chill-summer.mp3\",\n        \"Mellow Vibes Radio\"\n    ]\n}\n"
  },
  {
    "path": "utils/background_videos.json",
    "content": "{\n    \"__comment\": \"Supported Backgrounds. Can add/remove background video here...\",\n    \"motor-gta\": [\n        \"https://www.youtube.com/watch?v=vw5L4xCPy9Q\",\n        \"bike-parkour-gta.mp4\",\n        \"Achy Gaming\",\n        \"center\"\n    ],\n    \"rocket-league\": [\n        \"https://www.youtube.com/watch?v=2X9QGY__0II\",\n        \"rocket_league.mp4\",\n        \"Orbital Gameplay\",\n        \"center\"\n    ],\n    \"minecraft\": [\n        \"https://www.youtube.com/watch?v=n_Dv4JMiwK8\",\n        \"parkour.mp4\",\n        \"bbswitzer\",\n        \"center\"\n    ],\n    \"gta\": [\n        \"https://www.youtube.com/watch?v=qGa9kWREOnE\",\n        \"gta-stunt-race.mp4\",\n        \"Achy Gaming\",\n        \"center\"\n    ],\n    \"csgo-surf\": [\n        \"https://www.youtube.com/watch?v=E-8JlyO59Io\",\n        \"csgo-surf.mp4\",\n        \"Aki\",\n        \"center\"\n    ],\n    \"cluster-truck\": [\n        \"https://www.youtube.com/watch?v=uVKxtdMgJVU\",\n        \"cluster_truck.mp4\",\n        \"No Copyright Gameplay\",\n        \"center\"\n    ],\n    \"minecraft-2\": [\n        \"https://www.youtube.com/watch?v=Pt5_GSKIWQM\",\n        \"minecraft-2.mp4\",\n        \"Itslpsn\",\n        \"center\"\n    ],\n    \"multiversus\": [\n        \"https://www.youtube.com/watch?v=66oK1Mktz6g\",\n        \"multiversus.mp4\",\n        \"MKIceAndFire\",\n        \"center\"\n    ],\n    \"fall-guys\": [\n        \"https://www.youtube.com/watch?v=oGSsgACIc6Q\",\n        \"fall-guys.mp4\",\n        \"Throneful\",\n        \"center\"\n    ],\n    \"steep\": [\n        \"https://www.youtube.com/watch?v=EnGiQrWBrko\",\n        \"steep.mp4\",\n        \"joel\",\n        \"center\"\n    ]\n}\n"
  },
  {
    "path": "utils/cleanup.py",
    "content": "import os\nimport shutil\nfrom os.path import exists\n\n\ndef _listdir(d):  # listdir with full path\n    return [os.path.join(d, f) for f in os.listdir(d)]\n\n\ndef cleanup(reddit_id) -> int:\n    \"\"\"Deletes all temporary assets in assets/temp\n\n    Returns:\n        int: How many files were deleted\n    \"\"\"\n    directory = f\"../assets/temp/{reddit_id}/\"\n    if exists(directory):\n        shutil.rmtree(directory)\n\n        return 1\n"
  },
  {
    "path": "utils/console.py",
    "content": "import re\n\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.markdown import Markdown\nfrom rich.padding import Padding\nfrom rich.panel import Panel\nfrom rich.text import Text\n\nconsole = Console()\n\n\ndef print_markdown(text) -> None:\n    \"\"\"Prints a rich info message. Support Markdown syntax.\"\"\"\n\n    md = Padding(Markdown(text), 2)\n    console.print(md)\n\n\ndef print_step(text) -> None:\n    \"\"\"Prints a rich info message.\"\"\"\n\n    panel = Panel(Text(text, justify=\"left\"))\n    console.print(panel)\n\n\ndef print_table(items) -> None:\n    \"\"\"Prints items in a table.\"\"\"\n\n    console.print(Columns([Panel(f\"[yellow]{item}\", expand=True) for item in items]))\n\n\ndef print_substep(text, style=\"\") -> None:\n    \"\"\"Prints a rich colored info message without the panelling.\"\"\"\n    console.print(text, style=style)\n\n\ndef handle_input(\n    message: str = \"\",\n    check_type=False,\n    match: str = \"\",\n    err_message: str = \"\",\n    nmin=None,\n    nmax=None,\n    oob_error=\"\",\n    extra_info=\"\",\n    options: list = None,\n    default=NotImplemented,\n    optional=False,\n):\n    if optional:\n        console.print(message + \"\\n[green]This is an optional value. Do you want to skip it? (y/n)\")\n        if input().casefold().startswith(\"y\"):\n            return default if default is not NotImplemented else \"\"\n    if default is not NotImplemented:\n        console.print(\n            \"[green]\"\n            + message\n            + '\\n[blue bold]The default value is \"'\n            + str(default)\n            + '\"\\nDo you want to use it?(y/n)'\n        )\n        if input().casefold().startswith(\"y\"):\n            return default\n    if options is None:\n        match = re.compile(match)\n        console.print(\"[green bold]\" + extra_info, no_wrap=True)\n        while True:\n            console.print(message, end=\"\")\n            user_input = input(\"\").strip()\n            if check_type is not False:\n                try:\n                    user_input = check_type(user_input)\n                    if (nmin is not None and user_input < nmin) or (\n                        nmax is not None and user_input > nmax\n                    ):\n                        # FAILSTATE Input out of bounds\n                        console.print(\"[red]\" + oob_error)\n                        continue\n                    break  # Successful type conversion and number in bounds\n                except ValueError:\n                    # Type conversion failed\n                    console.print(\"[red]\" + err_message)\n                    continue\n            elif match != \"\" and re.match(match, user_input) is None:\n                console.print(\"[red]\" + err_message + \"\\nAre you absolutely sure it's correct?(y/n)\")\n                if input().casefold().startswith(\"y\"):\n                    break\n                continue\n            else:\n                # FAILSTATE Input STRING out of bounds\n                if (nmin is not None and len(user_input) < nmin) or (\n                    nmax is not None and len(user_input) > nmax\n                ):\n                    console.print(\"[red bold]\" + oob_error)\n                    continue\n                break  # SUCCESS Input STRING in bounds\n        return user_input\n    console.print(extra_info, no_wrap=True)\n    while True:\n        console.print(message, end=\"\")\n        user_input = input(\"\").strip()\n        if check_type is not False:\n            try:\n                isinstance(eval(user_input), check_type)  # fixme: remove eval\n                return check_type(user_input)\n            except:\n                console.print(\n                    \"[red bold]\"\n                    + err_message\n                    + \"\\nValid options are: \"\n                    + \", \".join(map(str, options))\n                    + \".\"\n                )\n                continue\n        if user_input in options:\n            return user_input\n        console.print(\n            \"[red bold]\" + err_message + \"\\nValid options are: \" + \", \".join(map(str, options)) + \".\"\n        )\n"
  },
  {
    "path": "utils/ffmpeg_install.py",
    "content": "import os\nimport subprocess\nimport zipfile\n\nimport requests\n\n\ndef ffmpeg_install_windows():\n    try:\n        ffmpeg_url = (\n            \"https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.zip\"\n        )\n        ffmpeg_zip_filename = \"ffmpeg.zip\"\n        ffmpeg_extracted_folder = \"ffmpeg\"\n\n        # Check if ffmpeg.zip already exists\n        if os.path.exists(ffmpeg_zip_filename):\n            os.remove(ffmpeg_zip_filename)\n\n        # Download FFmpeg\n        r = requests.get(ffmpeg_url)\n        with open(ffmpeg_zip_filename, \"wb\") as f:\n            f.write(r.content)\n\n        # Check if the extracted folder already exists\n        if os.path.exists(ffmpeg_extracted_folder):\n            # Remove existing extracted folder and its contents\n            for root, dirs, files in os.walk(ffmpeg_extracted_folder, topdown=False):\n                for file in files:\n                    os.remove(os.path.join(root, file))\n                for directory in dirs:\n                    os.rmdir(os.path.join(root, directory))\n            os.rmdir(ffmpeg_extracted_folder)\n\n        # Extract FFmpeg\n        with zipfile.ZipFile(ffmpeg_zip_filename, \"r\") as zip_ref:\n            zip_ref.extractall()\n        os.remove(\"ffmpeg.zip\")\n\n        # Rename and move files\n        os.rename(f\"{ffmpeg_extracted_folder}-6.0-full_build\", ffmpeg_extracted_folder)\n        for file in os.listdir(os.path.join(ffmpeg_extracted_folder, \"bin\")):\n            os.rename(\n                os.path.join(ffmpeg_extracted_folder, \"bin\", file),\n                os.path.join(\".\", file),\n            )\n        os.rmdir(os.path.join(ffmpeg_extracted_folder, \"bin\"))\n        for file in os.listdir(os.path.join(ffmpeg_extracted_folder, \"doc\")):\n            os.remove(os.path.join(ffmpeg_extracted_folder, \"doc\", file))\n        for file in os.listdir(os.path.join(ffmpeg_extracted_folder, \"presets\")):\n            os.remove(os.path.join(ffmpeg_extracted_folder, \"presets\", file))\n        os.rmdir(os.path.join(ffmpeg_extracted_folder, \"presets\"))\n        os.rmdir(os.path.join(ffmpeg_extracted_folder, \"doc\"))\n        os.remove(os.path.join(ffmpeg_extracted_folder, \"LICENSE\"))\n        os.remove(os.path.join(ffmpeg_extracted_folder, \"README.txt\"))\n        os.rmdir(ffmpeg_extracted_folder)\n\n        print(\n            \"FFmpeg installed successfully! Please restart your computer and then re-run the program.\"\n        )\n    except Exception as e:\n        print(\n            \"An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again.\"\n        )\n        print(e)\n        exit()\n\n\ndef ffmpeg_install_linux():\n    try:\n        subprocess.run(\n            \"sudo apt install ffmpeg\",\n            shell=True,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n        )\n    except Exception as e:\n        print(\n            \"An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again.\"\n        )\n        print(e)\n        exit()\n    print(\"FFmpeg installed successfully! Please re-run the program.\")\n    exit()\n\n\ndef ffmpeg_install_mac():\n    try:\n        subprocess.run(\n            \"brew install ffmpeg\",\n            shell=True,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n        )\n    except FileNotFoundError:\n        print(\n            \"Homebrew is not installed. Please install it and try again. Otherwise, please install FFmpeg manually and try again.\"\n        )\n        exit()\n    print(\"FFmpeg installed successfully! Please re-run the program.\")\n    exit()\n\n\ndef ffmpeg_install():\n    try:\n        # Try to run the FFmpeg command\n        subprocess.run(\n            [\"ffmpeg\", \"-version\"],\n            check=True,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n        )\n    except FileNotFoundError:\n        # Check if there's ffmpeg.exe in the current directory\n        if os.path.exists(\"./ffmpeg.exe\"):\n            print(\n                \"FFmpeg is installed on this system! If you are seeing this error for the second time, restart your computer.\"\n            )\n        print(\"FFmpeg is not installed on this system.\")\n        resp = input(\n            \"We can try to automatically install it for you. Would you like to do that? (y/n): \"\n        )\n        if resp.lower() == \"y\":\n            print(\"Installing FFmpeg...\")\n            if os.name == \"nt\":\n                ffmpeg_install_windows()\n            elif os.name == \"posix\":\n                ffmpeg_install_linux()\n            elif os.name == \"mac\":\n                ffmpeg_install_mac()\n            else:\n                print(\"Your OS is not supported. Please install FFmpeg manually and try again.\")\n                exit()\n        else:\n            print(\"Please install FFmpeg manually and try again.\")\n            exit()\n    except Exception as e:\n        print(\n            \"Welcome fellow traveler! You're one of the few who have made it this far. We have no idea how you got at this error, but we're glad you're here. Please report this error to the developer, and we'll try to fix it as soon as possible. Thank you for your patience!\"\n        )\n        print(e)\n    return None\n"
  },
  {
    "path": "utils/fonts.py",
    "content": "from PIL.ImageFont import FreeTypeFont, ImageFont\n\n\ndef getsize(font: ImageFont | FreeTypeFont, text: str):\n    left, top, right, bottom = font.getbbox(text)\n    width = right - left\n    height = bottom - top\n    return width, height\n\n\ndef getheight(font: ImageFont | FreeTypeFont, text: str):\n    _, height = getsize(font, text)\n    return height\n"
  },
  {
    "path": "utils/gui_utils.py",
    "content": "import json\nimport re\nfrom pathlib import Path\n\nimport toml\nimport tomlkit\nfrom flask import flash\n\n\n# Get validation checks from template\ndef get_checks():\n    template = toml.load(\"utils/.config.template.toml\")\n    checks = {}\n\n    def unpack_checks(obj: dict):\n        for key in obj.keys():\n            if \"optional\" in obj[key].keys():\n                checks[key] = obj[key]\n            else:\n                unpack_checks(obj[key])\n\n    unpack_checks(template)\n\n    return checks\n\n\n# Get current config (from config.toml) as dict\ndef get_config(obj: dict, done=None):\n    if done is None:\n        done = {}\n    for key in obj.keys():\n        if not isinstance(obj[key], dict):\n            done[key] = obj[key]\n        else:\n            get_config(obj[key], done)\n\n    return done\n\n\n# Checks if value is valid\ndef check(value, checks):\n    incorrect = False\n\n    if value == \"False\":\n        value = \"\"\n\n    if not incorrect and \"type\" in checks:\n        try:\n            value = eval(checks[\"type\"])(value)  # fixme remove eval\n        except Exception:\n            incorrect = True\n\n    if (\n        not incorrect and \"options\" in checks and value not in checks[\"options\"]\n    ):  # FAILSTATE Value isn't one of the options\n        incorrect = True\n    if (\n        not incorrect\n        and \"regex\" in checks\n        and (\n            (isinstance(value, str) and re.match(checks[\"regex\"], value) is None)\n            or not isinstance(value, str)\n        )\n    ):  # FAILSTATE Value doesn't match regular expression, or has regular expression but isn't a string.\n        incorrect = True\n\n    if (\n        not incorrect\n        and not hasattr(value, \"__iter__\")\n        and (\n            (\"nmin\" in checks and checks[\"nmin\"] is not None and value < checks[\"nmin\"])\n            or (\"nmax\" in checks and checks[\"nmax\"] is not None and value > checks[\"nmax\"])\n        )\n    ):\n        incorrect = True\n\n    if (\n        not incorrect\n        and hasattr(value, \"__iter__\")\n        and (\n            (\"nmin\" in checks and checks[\"nmin\"] is not None and len(value) < checks[\"nmin\"])\n            or (\"nmax\" in checks and checks[\"nmax\"] is not None and len(value) > checks[\"nmax\"])\n        )\n    ):\n        incorrect = True\n\n    if incorrect:\n        return \"Error\"\n\n    return value\n\n\n# Modify settings (after the form is submitted)\ndef modify_settings(data: dict, config_load, checks: dict):\n    # Modify config settings\n    def modify_config(obj: dict, config_name: str, value: any):\n        for key in obj.keys():\n            if config_name == key:\n                obj[key] = value\n            elif not isinstance(obj[key], dict):\n                continue\n            else:\n                modify_config(obj[key], config_name, value)\n\n    # Remove empty/incorrect key-value pairs\n    data = {key: value for key, value in data.items() if value and key in checks.keys()}\n\n    # Validate values\n    for name in data.keys():\n        value = check(data[name], checks[name])\n\n        # Value is invalid\n        if value == \"Error\":\n            flash(\"Some values were incorrect and didn't save!\", \"error\")\n        else:\n            # Value is valid\n            modify_config(config_load, name, value)\n\n    # Save changes in config.toml\n    with Path(\"config.toml\").open(\"w\") as toml_file:\n        toml_file.write(tomlkit.dumps(config_load))\n\n    flash(\"Settings saved!\")\n\n    return get_config(config_load)\n\n\n# Delete background video\ndef delete_background(key):\n    # Read backgrounds.json\n    with open(\"utils/backgrounds.json\", \"r\", encoding=\"utf-8\") as backgrounds:\n        data = json.load(backgrounds)\n\n    # Remove background from backgrounds.json\n    with open(\"utils/backgrounds.json\", \"w\", encoding=\"utf-8\") as backgrounds:\n        if data.pop(key, None):\n            json.dump(data, backgrounds, ensure_ascii=False, indent=4)\n        else:\n            flash(\"Couldn't find this background. Try refreshing the page.\", \"error\")\n            return\n\n    # Remove background video from \".config.template.toml\"\n    config = tomlkit.loads(Path(\"utils/.config.template.toml\").read_text())\n    config[\"settings\"][\"background\"][\"background_choice\"][\"options\"].remove(key)\n\n    with Path(\"utils/.config.template.toml\").open(\"w\") as toml_file:\n        toml_file.write(tomlkit.dumps(config))\n\n    flash(f'Successfully removed \"{key}\" background!')\n\n\n# Add background video\ndef add_background(youtube_uri, filename, citation, position):\n    # Validate YouTube URI\n    regex = re.compile(r\"(?:\\/|%3D|v=|vi=)([0-9A-z\\-_]{11})(?:[%#?&]|$)\").search(youtube_uri)\n\n    if not regex:\n        flash(\"YouTube URI is invalid!\", \"error\")\n        return\n\n    youtube_uri = f\"https://www.youtube.com/watch?v={regex.group(1)}\"\n\n    # Check if the position is valid\n    if position == \"\" or position == \"center\":\n        position = \"center\"\n\n    elif position.isdecimal():\n        position = int(position)\n\n    else:\n        flash('Position is invalid! It can be \"center\" or decimal number.', \"error\")\n        return\n\n    # Sanitize filename\n    regex = re.compile(r\"^([a-zA-Z0-9\\s_-]{1,100})$\").match(filename)\n\n    if not regex:\n        flash(\"Filename is invalid!\", \"error\")\n        return\n\n    filename = filename.replace(\" \", \"_\")\n\n    # Check if the background doesn't already exist\n    with open(\"utils/backgrounds.json\", \"r\", encoding=\"utf-8\") as backgrounds:\n        data = json.load(backgrounds)\n\n        # Check if key isn't already taken\n        if filename in list(data.keys()):\n            flash(\"Background video with this name already exist!\", \"error\")\n            return\n\n        # Check if the YouTube URI isn't already used under different name\n        if youtube_uri in [data[i][0] for i in list(data.keys())]:\n            flash(\"Background video with this YouTube URI is already added!\", \"error\")\n            return\n\n    # Add background video to json file\n    with open(\"utils/backgrounds.json\", \"r+\", encoding=\"utf-8\") as backgrounds:\n        data = json.load(backgrounds)\n\n        data[filename] = [youtube_uri, filename + \".mp4\", citation, position]\n        backgrounds.seek(0)\n        json.dump(data, backgrounds, ensure_ascii=False, indent=4)\n\n    # Add background video to \".config.template.toml\"\n    config = tomlkit.loads(Path(\"utils/.config.template.toml\").read_text())\n    config[\"settings\"][\"background\"][\"background_choice\"][\"options\"].append(filename)\n\n    with Path(\"utils/.config.template.toml\").open(\"w\") as toml_file:\n        toml_file.write(tomlkit.dumps(config))\n\n    flash(f'Added \"{citation}-{filename}.mp4\" as a new background video!')\n\n    return\n"
  },
  {
    "path": "utils/id.py",
    "content": "import re\r\nfrom typing import Optional\r\n\r\nfrom utils.console import print_substep\r\n\r\n\r\ndef extract_id(reddit_obj: dict, field: Optional[str] = \"thread_id\"):\r\n    \"\"\"\r\n    This function takes a reddit object and returns the post id\r\n    \"\"\"\r\n    if field not in reddit_obj.keys():\r\n        raise ValueError(f\"Field '{field}' not found in reddit object\")\r\n    reddit_id = re.sub(r\"[^\\w\\s-]\", \"\", reddit_obj[field])\r\n    return reddit_id\r\n"
  },
  {
    "path": "utils/imagenarator.py",
    "content": "import os\nimport re\nimport textwrap\n\nfrom PIL import Image, ImageDraw, ImageFont\nfrom rich.progress import track\n\nfrom TTS.engine_wrapper import process_text\nfrom utils.fonts import getheight, getsize\nfrom utils.id import extract_id\n\n\ndef draw_multiple_line_text(\n    image, text, font, text_color, padding, wrap=50, transparent=False\n) -> None:\n    \"\"\"\n    Draw multiline text over given image\n    \"\"\"\n    draw = ImageDraw.Draw(image)\n    font_height = getheight(font, text)\n    image_width, image_height = image.size\n    lines = textwrap.wrap(text, width=wrap)\n    y = (image_height / 2) - (((font_height + (len(lines) * padding) / len(lines)) * len(lines)) / 2)\n    for line in lines:\n        line_width, line_height = getsize(font, line)\n        if transparent:\n            shadowcolor = \"black\"\n            for i in range(1, 5):\n                draw.text(\n                    ((image_width - line_width) / 2 - i, y - i),\n                    line,\n                    font=font,\n                    fill=shadowcolor,\n                )\n                draw.text(\n                    ((image_width - line_width) / 2 + i, y - i),\n                    line,\n                    font=font,\n                    fill=shadowcolor,\n                )\n                draw.text(\n                    ((image_width - line_width) / 2 - i, y + i),\n                    line,\n                    font=font,\n                    fill=shadowcolor,\n                )\n                draw.text(\n                    ((image_width - line_width) / 2 + i, y + i),\n                    line,\n                    font=font,\n                    fill=shadowcolor,\n                )\n        draw.text(((image_width - line_width) / 2, y), line, font=font, fill=text_color)\n        y += line_height + padding\n\n\ndef imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) -> None:\n    \"\"\"\n    Render Images for video\n    \"\"\"\n    texts = reddit_obj[\"thread_post\"]\n    reddit_id = extract_id(reddit_obj)\n    if transparent:\n        font = ImageFont.truetype(os.path.join(\"fonts\", \"Roboto-Bold.ttf\"), 100)\n    else:\n        font = ImageFont.truetype(os.path.join(\"fonts\", \"Roboto-Regular.ttf\"), 100)\n\n    size = (1920, 1080)\n\n    for idx, text in track(enumerate(texts), \"Rendering Image\"):\n        image = Image.new(\"RGBA\", size, theme)\n        text = process_text(text, False)\n        draw_multiple_line_text(image, text, font, txtclr, padding, wrap=30, transparent=transparent)\n        image.save(f\"assets/temp/{reddit_id}/png/img{idx}.png\")\n"
  },
  {
    "path": "utils/playwright.py",
    "content": "def clear_cookie_by_name(context, cookie_cleared_name):\n    cookies = context.cookies()\n    filtered_cookies = [cookie for cookie in cookies if cookie[\"name\"] != cookie_cleared_name]\n    context.clear_cookies()\n    context.add_cookies(filtered_cookies)\n"
  },
  {
    "path": "utils/posttextparser.py",
    "content": "import os\nimport re\nimport time\nfrom typing import List\n\nimport spacy\n\nfrom utils.console import print_step\nfrom utils.voice import sanitize_text\n\n\n# working good\ndef posttextparser(obj, *, tried: bool = False) -> List[str]:\n    text: str = re.sub(\"\\n\", \" \", obj)\n    try:\n        nlp = spacy.load(\"en_core_web_sm\")\n    except OSError as e:\n        if not tried:\n            os.system(\"python -m spacy download en_core_web_sm\")\n            time.sleep(5)\n            return posttextparser(obj, tried=True)\n        print_step(\n            \"The spacy model can't load. You need to install it with the command \\npython -m spacy download en_core_web_sm \"\n        )\n        raise e\n\n    doc = nlp(text)\n\n    newtext: list = []\n\n    for line in doc.sents:\n        if sanitize_text(line.text):\n            newtext.append(line.text)\n\n    return newtext\n"
  },
  {
    "path": "utils/settings.py",
    "content": "import re\nfrom pathlib import Path\nfrom typing import Dict, Tuple\n\nimport toml\nfrom rich.console import Console\n\nfrom utils.console import handle_input\n\nconsole = Console()\nconfig = dict  # autocomplete\n\n\ndef crawl(obj: dict, func=lambda x, y: print(x, y, end=\"\\n\"), path=None):\n    if path is None:  # path Default argument value is mutable\n        path = []\n    for key in obj.keys():\n        if type(obj[key]) is dict:\n            crawl(obj[key], func, path + [key])\n            continue\n        func(path + [key], obj[key])\n\n\ndef check(value, checks, name):\n    def get_check_value(key, default_result):\n        return checks[key] if key in checks else default_result\n\n    incorrect = False\n    if value == {}:\n        incorrect = True\n    if not incorrect and \"type\" in checks:\n        try:\n            value = eval(checks[\"type\"])(value)  # fixme remove eval\n        except:\n            incorrect = True\n\n    if (\n        not incorrect and \"options\" in checks and value not in checks[\"options\"]\n    ):  # FAILSTATE Value is not one of the options\n        incorrect = True\n    if (\n        not incorrect\n        and \"regex\" in checks\n        and (\n            (isinstance(value, str) and re.match(checks[\"regex\"], value) is None)\n            or not isinstance(value, str)\n        )\n    ):  # FAILSTATE Value doesn't match regex, or has regex but is not a string.\n        incorrect = True\n\n    if (\n        not incorrect\n        and not hasattr(value, \"__iter__\")\n        and (\n            (\"nmin\" in checks and checks[\"nmin\"] is not None and value < checks[\"nmin\"])\n            or (\"nmax\" in checks and checks[\"nmax\"] is not None and value > checks[\"nmax\"])\n        )\n    ):\n        incorrect = True\n    if (\n        not incorrect\n        and hasattr(value, \"__iter__\")\n        and (\n            (\"nmin\" in checks and checks[\"nmin\"] is not None and len(value) < checks[\"nmin\"])\n            or (\"nmax\" in checks and checks[\"nmax\"] is not None and len(value) > checks[\"nmax\"])\n        )\n    ):\n        incorrect = True\n\n    if incorrect:\n        value = handle_input(\n            message=(\n                ((\"[blue]Example: \" + str(checks[\"example\"]) + \"\\n\") if \"example\" in checks else \"\")\n                + \"[red]\"\n                + (\"Non-optional \", \"Optional \")[\"optional\" in checks and checks[\"optional\"] is True]\n            )\n            + \"[#C0CAF5 bold]\"\n            + str(name)\n            + \"[#F7768E bold]=\",\n            extra_info=get_check_value(\"explanation\", \"\"),\n            check_type=eval(get_check_value(\"type\", \"False\")),  # fixme remove eval\n            default=get_check_value(\"default\", NotImplemented),\n            match=get_check_value(\"regex\", \"\"),\n            err_message=get_check_value(\"input_error\", \"Incorrect input\"),\n            nmin=get_check_value(\"nmin\", None),\n            nmax=get_check_value(\"nmax\", None),\n            oob_error=get_check_value(\n                \"oob_error\", \"Input out of bounds(Value too high/low/long/short)\"\n            ),\n            options=get_check_value(\"options\", None),\n            optional=get_check_value(\"optional\", False),\n        )\n    return value\n\n\ndef crawl_and_check(obj: dict, path: list, checks: dict = {}, name=\"\"):\n    if len(path) == 0:\n        return check(obj, checks, name)\n    if path[0] not in obj.keys():\n        obj[path[0]] = {}\n    obj[path[0]] = crawl_and_check(obj[path[0]], path[1:], checks, path[0])\n    return obj\n\n\ndef check_vars(path, checks):\n    global config\n    crawl_and_check(config, path, checks)\n\n\ndef check_toml(template_file, config_file) -> Tuple[bool, Dict]:\n    global config\n    config = None\n    try:\n        template = toml.load(template_file)\n    except Exception as error:\n        console.print(f\"[red bold]Encountered error when trying to to load {template_file}: {error}\")\n        return False\n    try:\n        config = toml.load(config_file)\n    except toml.TomlDecodeError:\n        console.print(\n            f\"\"\"[blue]Couldn't read {config_file}.\nOverwrite it?(y/n)\"\"\"\n        )\n        if not input().startswith(\"y\"):\n            print(\"Unable to read config, and not allowed to overwrite it. Giving up.\")\n            return False\n        else:\n            try:\n                with open(config_file, \"w\") as f:\n                    f.write(\"\")\n            except:\n                console.print(\n                    f\"[red bold]Failed to overwrite {config_file}. Giving up.\\nSuggestion: check {config_file} permissions for the user.\"\n                )\n                return False\n    except FileNotFoundError:\n        console.print(\n            f\"\"\"[blue]Couldn't find {config_file}\nCreating it now.\"\"\"\n        )\n        try:\n            with open(config_file, \"x\") as f:\n                f.write(\"\")\n            config = {}\n        except:\n            console.print(\n                f\"[red bold]Failed to write to {config_file}. Giving up.\\nSuggestion: check the folder's permissions for the user.\"\n            )\n            return False\n\n    console.print(\n        \"\"\"\\\n[blue bold]###############################\n#                             #\n# Checking TOML configuration #\n#                             #\n###############################\nIf you see any prompts, that means that you have unset/incorrectly set variables, please input the correct values.\\\n\"\"\"\n    )\n    crawl(template, check_vars)\n    with open(config_file, \"w\") as f:\n        toml.dump(config, f)\n    return config\n\n\nif __name__ == \"__main__\":\n    directory = Path().absolute()\n    check_toml(f\"{directory}/utils/.config.template.toml\", \"config.toml\")\n"
  },
  {
    "path": "utils/subreddit.py",
    "content": "import json\nfrom os.path import exists\n\nfrom utils import settings\nfrom utils.ai_methods import sort_by_similarity\nfrom utils.console import print_substep\n\n\ndef _contains_blocked_words(text: str) -> bool:\n    \"\"\"Returns True if the text contains any blocked words from config.\"\"\"\n    blocked_raw = settings.config[\"reddit\"][\"thread\"].get(\"blocked_words\", \"\")\n    if not blocked_raw:\n        return False\n    blocked = [w.strip().lower() for w in blocked_raw.split(\",\") if w.strip()]\n    text_lower = text.lower()\n    return any(word in text_lower for word in blocked)\n\n\ndef get_subreddit_undone(submissions: list, subreddit, times_checked=0, similarity_scores=None):\n    \"\"\"_summary_\n\n    Args:\n        submissions (list): List of posts that are going to potentially be generated into a video\n        subreddit (praw.Reddit.SubredditHelper): Chosen subreddit\n\n    Returns:\n        Any: The submission that has not been done\n    \"\"\"\n    # Second try of getting a valid Submission\n    if times_checked and settings.config[\"ai\"][\"ai_similarity_enabled\"]:\n        print(\"Sorting based on similarity for a different date filter and thread limit..\")\n        submissions = sort_by_similarity(\n            submissions, keywords=settings.config[\"ai\"][\"ai_similarity_enabled\"]\n        )\n\n    # recursively checks if the top submission in the list was already done.\n    if not exists(\"./video_creation/data/videos.json\"):\n        with open(\"./video_creation/data/videos.json\", \"w+\") as f:\n            json.dump([], f)\n    with open(\"./video_creation/data/videos.json\", \"r\", encoding=\"utf-8\") as done_vids_raw:\n        done_videos = json.load(done_vids_raw)\n    for i, submission in enumerate(submissions):\n        if already_done(done_videos, submission):\n            continue\n        if submission.over_18:\n            try:\n                if not settings.config[\"settings\"][\"allow_nsfw\"]:\n                    print_substep(\"NSFW Post Detected. Skipping...\")\n                    continue\n            except AttributeError:\n                print_substep(\"NSFW settings not defined. Skipping NSFW post...\")\n        if submission.stickied:\n            print_substep(\"This post was pinned by moderators. Skipping...\")\n            continue\n        if _contains_blocked_words(submission.title + \" \" + (submission.selftext or \"\")):\n            print_substep(\"Post contains a blocked word. Skipping...\")\n            continue\n        if (\n            submission.num_comments <= int(settings.config[\"reddit\"][\"thread\"][\"min_comments\"])\n            and not settings.config[\"settings\"][\"storymode\"]\n        ):\n            print_substep(\n                f'This post has under the specified minimum of comments ({settings.config[\"reddit\"][\"thread\"][\"min_comments\"]}). Skipping...'\n            )\n            continue\n        if settings.config[\"settings\"][\"storymode\"]:\n            if not submission.selftext:\n                print_substep(\"You are trying to use story mode on post with no post text\")\n                continue\n            else:\n                # Check for the length of the post text\n                if len(submission.selftext) > (\n                    settings.config[\"settings\"][\"storymode_max_length\"] or 2000\n                ):\n                    print_substep(\n                        f\"Post is too long ({len(submission.selftext)}), try with a different post. ({settings.config['settings']['storymode_max_length']} character limit)\"\n                    )\n                    continue\n                elif len(submission.selftext) < 30:\n                    continue\n        if settings.config[\"settings\"][\"storymode\"] and not submission.is_self:\n            continue\n        if similarity_scores is not None:\n            return submission, similarity_scores[i].item()\n        return submission\n    print(\"all submissions have been done going by top submission order\")\n    VALID_TIME_FILTERS = [\n        \"day\",\n        \"hour\",\n        \"month\",\n        \"week\",\n        \"year\",\n        \"all\",\n    ]  # set doesn't have __getitem__\n    index = times_checked + 1\n    if index == len(VALID_TIME_FILTERS):\n        print(\"All submissions have been done.\")\n\n    return get_subreddit_undone(\n        subreddit.top(\n            time_filter=VALID_TIME_FILTERS[index],\n            limit=(50 if int(index) == 0 else index + 1 * 50),\n        ),\n        subreddit,\n        times_checked=index,\n    )  # all the videos in hot have already been done\n\n\ndef already_done(done_videos: list, submission) -> bool:\n    \"\"\"Checks to see if the given submission is in the list of videos\n\n    Args:\n        done_videos (list): Finished videos\n        submission (Any): The submission\n\n    Returns:\n        Boolean: Whether the video was found in the list\n    \"\"\"\n\n    for video in done_videos:\n        if video[\"id\"] == str(submission):\n            return True\n    return False\n"
  },
  {
    "path": "utils/thumbnail.py",
    "content": "from PIL import ImageDraw, ImageFont\n\n\ndef create_thumbnail(thumbnail, font_family, font_size, font_color, width, height, title):\n    font = ImageFont.truetype(font_family + \".ttf\", font_size)\n    Xaxis = width - (width * 0.2)  # 20% of the width\n    sizeLetterXaxis = font_size * 0.5  # 50% of the font size\n    XaxisLetterQty = round(Xaxis / sizeLetterXaxis)  # Quantity of letters that can fit in the X axis\n    MarginYaxis = height * 0.12  # 12% of the height\n    MarginXaxis = width * 0.05  # 5% of the width\n    # 1.1 rem\n    LineHeight = font_size * 1.1\n    # rgb = \"255,255,255\" transform to list\n    rgb = font_color.split(\",\")\n    rgb = (int(rgb[0]), int(rgb[1]), int(rgb[2]))\n\n    arrayTitle = []\n    for word in title.split():\n        if len(arrayTitle) == 0:\n            # colocar a primeira palavra no arrayTitl# put the first word in the arrayTitle\n            arrayTitle.append(word)\n        else:\n            # if the size of arrayTitle is less than qtLetters\n            if len(arrayTitle[-1]) + len(word) < XaxisLetterQty:\n                arrayTitle[-1] = arrayTitle[-1] + \" \" + word\n            else:\n                arrayTitle.append(word)\n\n    draw = ImageDraw.Draw(thumbnail)\n    # loop for put the title in the thumbnail\n    for i in range(0, len(arrayTitle)):\n        # 1.1 rem\n        draw.text((MarginXaxis, MarginYaxis + (LineHeight * i)), arrayTitle[i], rgb, font=font)\n\n    return thumbnail\n"
  },
  {
    "path": "utils/version.py",
    "content": "import requests\r\n\r\nfrom utils.console import print_step\r\n\r\n\r\ndef checkversion(__VERSION__: str):\r\n    response = requests.get(\r\n        \"https://api.github.com/repos/elebumm/RedditVideoMakerBot/releases/latest\"\r\n    )\r\n    latestversion = response.json()[\"tag_name\"]\r\n    if __VERSION__ == latestversion:\r\n        print_step(f\"You are using the newest version ({__VERSION__}) of the bot\")\r\n        return True\r\n    elif __VERSION__ < latestversion:\r\n        print_step(\r\n            f\"You are using an older version ({__VERSION__}) of the bot. Download the newest version ({latestversion}) from https://github.com/elebumm/RedditVideoMakerBot/releases/latest\"\r\n        )\r\n    else:\r\n        print_step(\r\n            f\"Welcome to the test version ({__VERSION__}) of the bot. Thanks for testing and feel free to report any bugs you find.\"\r\n        )\r\n"
  },
  {
    "path": "utils/videos.py",
    "content": "import json\nimport time\n\nfrom praw.models import Submission\n\nfrom utils import settings\nfrom utils.console import print_step\n\n\ndef check_done(\n    redditobj: Submission,\n) -> Submission:\n    # don't set this to be run anyplace that isn't subreddit.py bc of inspect stack\n    \"\"\"Checks if the chosen post has already been generated\n\n    Args:\n        redditobj (Submission): Reddit object gotten from reddit/subreddit.py\n\n    Returns:\n        Submission|None: Reddit object in args\n    \"\"\"\n    with open(\"./video_creation/data/videos.json\", \"r\", encoding=\"utf-8\") as done_vids_raw:\n        done_videos = json.load(done_vids_raw)\n    for video in done_videos:\n        if video[\"id\"] == str(redditobj):\n            if settings.config[\"reddit\"][\"thread\"][\"post_id\"]:\n                print_step(\n                    \"You already have done this video but since it was declared specifically in the config file the program will continue\"\n                )\n                return redditobj\n            print_step(\"Getting new post as the current one has already been done\")\n            return None\n    return redditobj\n\n\ndef save_data(subreddit: str, filename: str, reddit_title: str, reddit_id: str, credit: str):\n    \"\"\"Saves the videos that have already been generated to a JSON file in video_creation/data/videos.json\n\n    Args:\n        filename (str): The finished video title name\n        @param subreddit:\n        @param filename:\n        @param reddit_id:\n        @param reddit_title:\n    \"\"\"\n    with open(\"./video_creation/data/videos.json\", \"r+\", encoding=\"utf-8\") as raw_vids:\n        done_vids = json.load(raw_vids)\n        if reddit_id in [video[\"id\"] for video in done_vids]:\n            return  # video already done but was specified to continue anyway in the config file\n        payload = {\n            \"subreddit\": subreddit,\n            \"id\": reddit_id,\n            \"time\": str(int(time.time())),\n            \"background_credit\": credit,\n            \"reddit_title\": reddit_title,\n            \"filename\": filename,\n        }\n        done_vids.append(payload)\n        raw_vids.seek(0)\n        json.dump(done_vids, raw_vids, ensure_ascii=False, indent=4)\n"
  },
  {
    "path": "utils/voice.py",
    "content": "import re\nimport sys\nimport time as pytime\nfrom datetime import datetime\nfrom time import sleep\n\nfrom cleantext import clean\nfrom requests import Response\n\nfrom utils import settings\n\nif sys.version_info[0] >= 3:\n    from datetime import timezone\n\n\ndef check_ratelimit(response: Response) -> bool:\n    \"\"\"\n    Checks if the response is a ratelimit response.\n    If it is, it sleeps for the time specified in the response.\n    \"\"\"\n    if response.status_code == 429:\n        try:\n            time = int(response.headers[\"X-RateLimit-Reset\"])\n            print(f\"Ratelimit hit. Sleeping for {time - int(pytime.time())} seconds.\")\n            sleep_until(time)\n            return False\n        except KeyError:  # if the header is not present, we don't know how long to wait\n            return False\n\n    return True\n\n\ndef sleep_until(time) -> None:\n    \"\"\"\n    Pause your program until a specific end time.\n    'time' is either a valid datetime object or unix timestamp in seconds (i.e. seconds since Unix epoch)\n    \"\"\"\n    end = time\n\n    # Convert datetime to unix timestamp and adjust for locality\n    if isinstance(time, datetime):\n        # If we're on Python 3 and the user specified a timezone, convert to UTC and get the timestamp.\n        if sys.version_info[0] >= 3 and time.tzinfo:\n            end = time.astimezone(timezone.utc).timestamp()\n        else:\n            zoneDiff = pytime.time() - (datetime.now() - datetime(1970, 1, 1)).total_seconds()\n            end = (time - datetime(1970, 1, 1)).total_seconds() + zoneDiff\n\n    # Type check\n    if not isinstance(end, (int, float)):\n        raise Exception(\"The time parameter is not a number or datetime object\")\n\n    # Now we wait\n    while True:\n        now = pytime.time()\n        diff = end - now\n\n        #\n        # Time is up!\n        #\n        if diff <= 0:\n            break\n        else:\n            # 'logarithmic' sleeping to minimize loop iterations\n            sleep(diff / 2)\n\n\ndef sanitize_text(text: str) -> str:\n    r\"\"\"Sanitizes the text for tts.\n        What gets removed:\n     - following characters`^_~@!&;#:-%“”‘\"%*/{}[]()\\|<>?=+`\n     - any http or https links\n\n    Args:\n        text (str): Text to be sanitized\n\n    Returns:\n        str: Sanitized text\n    \"\"\"\n\n    # remove any urls from the text\n    regex_urls = r\"((http|https)\\:\\/\\/)?[a-zA-Z0-9\\.\\/\\?\\:@\\-_=#]+\\.([a-zA-Z]){2,6}([a-zA-Z0-9\\.\\&\\/\\?\\:@\\-_=#])*\"\n\n    result = re.sub(regex_urls, \" \", text)\n\n    # note: not removing apostrophes\n    regex_expr = r\"\\s['|’]|['|’]\\s|[\\^_~@!&;#:\\-%—“”‘\\\"%\\*/{}\\[\\]\\(\\)\\\\|<>=+]\"\n    result = re.sub(regex_expr, \" \", result)\n    result = result.replace(\"+\", \"plus\").replace(\"&\", \"and\")\n\n    # emoji removal if the setting is enabled\n    if settings.config[\"settings\"][\"tts\"][\"no_emojis\"]:\n        result = clean(result, no_emoji=True)\n\n    # remove extra whitespace\n    return \" \".join(result.split())\n"
  },
  {
    "path": "video_creation/__init__.py",
    "content": ""
  },
  {
    "path": "video_creation/background.py",
    "content": "import json\nimport random\nimport re\nfrom pathlib import Path\nfrom random import randrange\nfrom typing import Any, Dict, Tuple\n\nimport yt_dlp\nfrom moviepy import AudioFileClip, VideoFileClip\nfrom moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip\n\nfrom utils import settings\nfrom utils.console import print_step, print_substep\n\n\ndef load_background_options():\n    _background_options = {}\n    # Load background videos\n    with open(\"./utils/background_videos.json\") as json_file:\n        _background_options[\"video\"] = json.load(json_file)\n\n    # Load background audios\n    with open(\"./utils/background_audios.json\") as json_file:\n        _background_options[\"audio\"] = json.load(json_file)\n\n    # Remove \"__comment\" from backgrounds\n    del _background_options[\"video\"][\"__comment\"]\n    del _background_options[\"audio\"][\"__comment\"]\n\n    for name in list(_background_options[\"video\"].keys()):\n        pos = _background_options[\"video\"][name][3]\n\n        if pos != \"center\":\n            _background_options[\"video\"][name][3] = lambda t: (\"center\", pos + t)\n\n    return _background_options\n\n\ndef get_start_and_end_times(video_length: int, length_of_clip: int) -> Tuple[int, int]:\n    \"\"\"Generates a random interval of time to be used as the background of the video.\n\n    Args:\n        video_length (int): Length of the video\n        length_of_clip (int): Length of the video to be used as the background\n\n    Returns:\n        tuple[int,int]: Start and end time of the randomized interval\n    \"\"\"\n    initialValue = 180\n    # Issue #1649 - Ensures that will be a valid interval in the video\n    while int(length_of_clip) <= int(video_length + initialValue):\n        if initialValue == initialValue // 2:\n            raise Exception(\"Your background is too short for this video length\")\n        else:\n            initialValue //= 2  # Divides the initial value by 2 until reach 0\n    random_time = randrange(initialValue, int(length_of_clip) - int(video_length))\n    return random_time, random_time + video_length\n\n\ndef get_background_config(mode: str):\n    \"\"\"Fetch the background/s configuration\"\"\"\n    try:\n        choice = str(settings.config[\"settings\"][\"background\"][f\"background_{mode}\"]).casefold()\n    except AttributeError:\n        print_substep(\"No background selected. Picking random background'\")\n        choice = None\n\n    # Handle default / not supported background using default option.\n    # Default : pick random from supported background.\n    if not choice or choice not in background_options[mode]:\n        choice = random.choice(list(background_options[mode].keys()))\n\n    return background_options[mode][choice]\n\n\ndef download_background_video(background_config: Tuple[str, str, str, Any]):\n    \"\"\"Downloads the background/s video from YouTube.\"\"\"\n    Path(\"./assets/backgrounds/video/\").mkdir(parents=True, exist_ok=True)\n    # note: make sure the file name doesn't include an - in it\n    uri, filename, credit, _ = background_config\n    if Path(f\"assets/backgrounds/video/{credit}-{filename}\").is_file():\n        return\n    print_step(\n        \"We need to download the backgrounds videos. they are fairly large but it's only done once. 😎\"\n    )\n    print_substep(\"Downloading the backgrounds videos... please be patient 🙏 \")\n    print_substep(f\"Downloading {filename} from {uri}\")\n    ydl_opts = {\n        \"format\": \"bestvideo[height<=1080][ext=mp4]\",\n        \"outtmpl\": f\"assets/backgrounds/video/{credit}-{filename}\",\n        \"retries\": 10,\n    }\n\n    with yt_dlp.YoutubeDL(ydl_opts) as ydl:\n        ydl.download(uri)\n    print_substep(\"Background video downloaded successfully! 🎉\", style=\"bold green\")\n\n\ndef download_background_audio(background_config: Tuple[str, str, str]):\n    \"\"\"Downloads the background/s audio from YouTube.\"\"\"\n    Path(\"./assets/backgrounds/audio/\").mkdir(parents=True, exist_ok=True)\n    # note: make sure the file name doesn't include an - in it\n    uri, filename, credit = background_config\n    if Path(f\"assets/backgrounds/audio/{credit}-{filename}\").is_file():\n        return\n    print_step(\n        \"We need to download the backgrounds audio. they are fairly large but it's only done once. 😎\"\n    )\n    print_substep(\"Downloading the backgrounds audio... please be patient 🙏 \")\n    print_substep(f\"Downloading {filename} from {uri}\")\n    ydl_opts = {\n        \"outtmpl\": f\"./assets/backgrounds/audio/{credit}-{filename}\",\n        \"format\": \"bestaudio/best\",\n        \"extract_audio\": True,\n    }\n\n    with yt_dlp.YoutubeDL(ydl_opts) as ydl:\n        ydl.download([uri])\n\n    print_substep(\"Background audio downloaded successfully! 🎉\", style=\"bold green\")\n\n\ndef chop_background(background_config: Dict[str, Tuple], video_length: int, reddit_object: dict):\n    \"\"\"Generates the background audio and footage to be used in the video and writes it to assets/temp/background.mp3 and assets/temp/background.mp4\n\n    Args:\n        reddit_object (Dict[str,str]) : Reddit object\n        background_config (Dict[str,Tuple]]) : Current background configuration\n        video_length (int): Length of the clip where the background footage is to be taken out of\n    \"\"\"\n    thread_id = re.sub(r\"[^\\w\\s-]\", \"\", reddit_object[\"thread_id\"])\n\n    if settings.config[\"settings\"][\"background\"][f\"background_audio_volume\"] == 0:\n        print_step(\"Volume was set to 0. Skipping background audio creation . . .\")\n    else:\n        print_step(\"Finding a spot in the backgrounds audio to chop...✂️\")\n        audio_choice = f\"{background_config['audio'][2]}-{background_config['audio'][1]}\"\n        background_audio = AudioFileClip(f\"assets/backgrounds/audio/{audio_choice}\")\n        start_time_audio, end_time_audio = get_start_and_end_times(\n            video_length, background_audio.duration\n        )\n        background_audio = background_audio.subclipped(start_time_audio, end_time_audio)\n        background_audio.write_audiofile(f\"assets/temp/{thread_id}/background.mp3\")\n\n    print_step(\"Finding a spot in the backgrounds video to chop...✂️\")\n    video_choice = f\"{background_config['video'][2]}-{background_config['video'][1]}\"\n    background_video = VideoFileClip(f\"assets/backgrounds/video/{video_choice}\")\n    start_time_video, end_time_video = get_start_and_end_times(\n        video_length, background_video.duration\n    )\n    # Extract video subclip\n    try:\n        with VideoFileClip(f\"assets/backgrounds/video/{video_choice}\") as video:\n            new = video.subclipped(start_time_video, end_time_video)\n            new.write_videofile(f\"assets/temp/{thread_id}/background.mp4\")\n\n    except (OSError, IOError):  # ffmpeg issue see #348\n        print_substep(\"FFMPEG issue. Trying again...\")\n        ffmpeg_extract_subclip(\n            f\"assets/backgrounds/video/{video_choice}\",\n            start_time_video,\n            end_time_video,\n            outputfile=f\"assets/temp/{thread_id}/background.mp4\",\n        )\n    print_substep(\"Background video chopped successfully!\", style=\"bold green\")\n    return background_config[\"video\"][2]\n\n\n# Create a tuple for downloads background (background_audio_options, background_video_options)\nbackground_options = load_background_options()\n"
  },
  {
    "path": "video_creation/data/cookie-dark-mode.json",
    "content": "[\n  {\n\t\"name\": \"USER\",\n\t\"value\": \"eyJwcmVmcyI6eyJ0b3BDb250ZW50RGlzbWlzc2FsVGltZSI6MCwiZ2xvYmFsVGhlbWUiOiJSRURESVQiLCJuaWdodG1vZGUiOnRydWUsImNvbGxhcHNlZFRyYXlTZWN0aW9ucyI6eyJmYXZvcml0ZXMiOmZhbHNlLCJtdWx0aXMiOmZhbHNlLCJtb2RlcmF0aW5nIjpmYWxzZSwic3Vic2NyaXB0aW9ucyI6ZmFsc2UsInByb2ZpbGVzIjpmYWxzZX0sInRvcENvbnRlbnRUaW1lc0Rpc21pc3NlZCI6MH19\",\n\t\"domain\": \".reddit.com\",\n\t\"path\": \"/\"\n  },\n  {\n\t\"name\": \"eu_cookie\",\n\t\"value\": \"{%22opted%22:true%2C%22nonessential%22:false}\",\n\t\"domain\": \".reddit.com\",\n\t\"path\": \"/\"\n  }\n]\n"
  },
  {
    "path": "video_creation/data/cookie-light-mode.json",
    "content": "[\n  {\n\t\"name\": \"eu_cookie\",\n\t\"value\": \"{%22opted%22:true%2C%22nonessential%22:false}\",\n\t\"domain\": \".reddit.com\",\n\t\"path\": \"/\"\n  }\n]\n"
  },
  {
    "path": "video_creation/final_video.py",
    "content": "import multiprocessing\nimport os\nimport re\nimport tempfile\nimport textwrap\nimport threading\nimport time\nfrom os.path import exists  # Needs to be imported specifically\nfrom pathlib import Path\nfrom typing import Dict, Final, Tuple\n\nimport ffmpeg\nimport translators\nfrom PIL import Image, ImageDraw, ImageFont\nfrom rich.console import Console\nfrom rich.progress import track\n\nfrom utils import settings\nfrom utils.cleanup import cleanup\nfrom utils.console import print_step, print_substep\nfrom utils.fonts import getheight\nfrom utils.id import extract_id\nfrom utils.thumbnail import create_thumbnail\nfrom utils.videos import save_data\n\nconsole = Console()\n\n\nclass ProgressFfmpeg(threading.Thread):\n    def __init__(self, vid_duration_seconds, progress_update_callback):\n        threading.Thread.__init__(self, name=\"ProgressFfmpeg\")\n        self.stop_event = threading.Event()\n        self.output_file = tempfile.NamedTemporaryFile(mode=\"w+\", delete=False)\n        self.vid_duration_seconds = vid_duration_seconds\n        self.progress_update_callback = progress_update_callback\n\n    def run(self):\n        while not self.stop_event.is_set():\n            latest_progress = self.get_latest_ms_progress()\n            if latest_progress is not None:\n                completed_percent = latest_progress / self.vid_duration_seconds\n                self.progress_update_callback(completed_percent)\n            time.sleep(1)\n\n    def get_latest_ms_progress(self):\n        lines = self.output_file.readlines()\n\n        if lines:\n            for line in lines:\n                if \"out_time_ms\" in line:\n                    out_time_ms_str = line.split(\"=\")[1].strip()\n                    if out_time_ms_str.isnumeric():\n                        return float(out_time_ms_str) / 1000000.0\n                    else:\n                        # Handle the case when \"N/A\" is encountered\n                        return None\n        return None\n\n    def stop(self):\n        self.stop_event.set()\n\n    def __enter__(self):\n        self.start()\n        return self\n\n    def __exit__(self, *args, **kwargs):\n        self.stop()\n\n\ndef name_normalize(name: str) -> str:\n    name = re.sub(r'[?\\\\\"%*:|<>]', \"\", name)\n    name = re.sub(r\"( [w,W]\\s?\\/\\s?[o,O,0])\", r\" without\", name)\n    name = re.sub(r\"( [w,W]\\s?\\/)\", r\" with\", name)\n    name = re.sub(r\"(\\d+)\\s?\\/\\s?(\\d+)\", r\"\\1 of \\2\", name)\n    name = re.sub(r\"(\\w+)\\s?\\/\\s?(\\w+)\", r\"\\1 or \\2\", name)\n    name = re.sub(r\"\\/\", r\"\", name)\n\n    lang = settings.config[\"reddit\"][\"thread\"][\"post_lang\"]\n    if lang:\n        print_substep(\"Translating filename...\")\n        translated_name = translators.translate_text(name, translator=\"google\", to_language=lang)\n        return translated_name\n    else:\n        return name\n\n\ndef prepare_background(reddit_id: str, W: int, H: int) -> str:\n    output_path = f\"assets/temp/{reddit_id}/background_noaudio.mp4\"\n    output = (\n        ffmpeg.input(f\"assets/temp/{reddit_id}/background.mp4\")\n        .filter(\"crop\", f\"ih*({W}/{H})\", \"ih\")\n        .output(\n            output_path,\n            an=None,\n            **{\n                \"c:v\": \"h264_nvenc\",\n                \"b:v\": \"20M\",\n                \"b:a\": \"192k\",\n                \"threads\": multiprocessing.cpu_count(),\n            },\n        )\n        .overwrite_output()\n    )\n    try:\n        output.run(quiet=True)\n    except ffmpeg.Error as e:\n        print(e.stderr.decode(\"utf8\"))\n        exit(1)\n    return output_path\n\n\ndef get_text_height(draw, text, font, max_width):\n    lines = textwrap.wrap(text, width=max_width)\n    total_height = 0\n    for line in lines:\n        _, _, _, height = draw.textbbox((0, 0), line, font=font)\n        total_height += height\n    return total_height\n\n\ndef create_fancy_thumbnail(image, text, text_color, padding, wrap=35):\n    \"\"\"\n    It will take the 1px from the middle of the template and will be resized (stretched) vertically to accommodate the extra height needed for the title.\n    \"\"\"\n    print_step(f\"Creating fancy thumbnail for: {text}\")\n    font_title_size = 47\n    font = ImageFont.truetype(os.path.join(\"fonts\", \"Roboto-Bold.ttf\"), font_title_size)\n    image_width, image_height = image.size\n\n    # Calculate text height to determine new image height\n    draw = ImageDraw.Draw(image)\n    text_height = get_text_height(draw, text, font, wrap)\n    lines = textwrap.wrap(text, width=wrap)\n    # This is -50 to reduce the empty space at the bottom of the image,\n    # change it as per your requirement if needed otherwise leave it.\n    new_image_height = image_height + text_height + padding * (len(lines) - 1) - 50\n\n    # Separate the image into top, middle (1px), and bottom parts\n    top_part_height = image_height // 2\n    middle_part_height = 1  # 1px height middle section\n    bottom_part_height = image_height - top_part_height - middle_part_height\n\n    top_part = image.crop((0, 0, image_width, top_part_height))\n    middle_part = image.crop((0, top_part_height, image_width, top_part_height + middle_part_height))\n    bottom_part = image.crop((0, top_part_height + middle_part_height, image_width, image_height))\n\n    # Stretch the middle part\n    new_middle_height = new_image_height - top_part_height - bottom_part_height\n    middle_part = middle_part.resize((image_width, new_middle_height))\n\n    # Create new image with the calculated height\n    new_image = Image.new(\"RGBA\", (image_width, new_image_height))\n\n    # Paste the top, stretched middle, and bottom parts into the new image\n    new_image.paste(top_part, (0, 0))\n    new_image.paste(middle_part, (0, top_part_height))\n    new_image.paste(bottom_part, (0, top_part_height + new_middle_height))\n\n    # Draw the title text on the new image\n    draw = ImageDraw.Draw(new_image)\n    y = top_part_height + padding\n    for line in lines:\n        draw.text((120, y), line, font=font, fill=text_color, align=\"left\")\n        y += get_text_height(draw, line, font, wrap) + padding\n\n    # Draw the username \"PlotPulse\" at the specific position\n    username_font = ImageFont.truetype(os.path.join(\"fonts\", \"Roboto-Bold.ttf\"), 30)\n    draw.text(\n        (205, 825),\n        settings.config[\"settings\"][\"channel_name\"],\n        font=username_font,\n        fill=text_color,\n        align=\"left\",\n    )\n\n    return new_image\n\n\ndef merge_background_audio(audio: ffmpeg, reddit_id: str):\n    \"\"\"Gather an audio and merge with assets/backgrounds/background.mp3\n    Args:\n        audio (ffmpeg): The TTS final audio but without background.\n        reddit_id (str): The ID of subreddit\n    \"\"\"\n    background_audio_volume = settings.config[\"settings\"][\"background\"][\"background_audio_volume\"]\n    if background_audio_volume == 0:\n        return audio  # Return the original audio\n    else:\n        # sets volume to config\n        bg_audio = ffmpeg.input(f\"assets/temp/{reddit_id}/background.mp3\").filter(\n            \"volume\",\n            background_audio_volume,\n        )\n        # Merges audio and background_audio\n        merged_audio = ffmpeg.filter([audio, bg_audio], \"amix\", duration=\"longest\")\n        return merged_audio  # Return merged audio\n\n\ndef make_final_video(\n    number_of_clips: int,\n    length: int,\n    reddit_obj: dict,\n    background_config: Dict[str, Tuple],\n):\n    \"\"\"Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp\n    Args:\n        number_of_clips (int): Index to end at when going through the screenshots'\n        length (int): Length of the video\n        reddit_obj (dict): The reddit object that contains the posts to read.\n        background_config (Tuple[str, str, str, Any]): The background config to use.\n    \"\"\"\n    # settings values\n    W: Final[int] = int(settings.config[\"settings\"][\"resolution_w\"])\n    H: Final[int] = int(settings.config[\"settings\"][\"resolution_h\"])\n\n    opacity = settings.config[\"settings\"][\"opacity\"]\n\n    reddit_id = extract_id(reddit_obj)\n\n    allowOnlyTTSFolder: bool = (\n        settings.config[\"settings\"][\"background\"][\"enable_extra_audio\"]\n        and settings.config[\"settings\"][\"background\"][\"background_audio_volume\"] != 0\n    )\n\n    print_step(\"Creating the final video 🎥\")\n\n    background_clip = ffmpeg.input(prepare_background(reddit_id, W=W, H=H))\n\n    # Gather all audio clips\n    audio_clips = list()\n    if number_of_clips == 0 and settings.config[\"settings\"][\"storymode\"] == \"false\":\n        print(\n            \"No audio clips to gather. Please use a different TTS or post.\"\n        )  # This is to fix the TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'\n        exit()\n    if settings.config[\"settings\"][\"storymode\"]:\n        if settings.config[\"settings\"][\"storymodemethod\"] == 0:\n            audio_clips = [ffmpeg.input(f\"assets/temp/{reddit_id}/mp3/title.mp3\")]\n            audio_clips.insert(1, ffmpeg.input(f\"assets/temp/{reddit_id}/mp3/postaudio.mp3\"))\n        elif settings.config[\"settings\"][\"storymodemethod\"] == 1:\n            audio_clips = [\n                ffmpeg.input(f\"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3\")\n                for i in track(range(number_of_clips + 1), \"Collecting the audio files...\")\n            ]\n            audio_clips.insert(0, ffmpeg.input(f\"assets/temp/{reddit_id}/mp3/title.mp3\"))\n\n    else:\n        audio_clips = [\n            ffmpeg.input(f\"assets/temp/{reddit_id}/mp3/{i}.mp3\") for i in range(number_of_clips)\n        ]\n        audio_clips.insert(0, ffmpeg.input(f\"assets/temp/{reddit_id}/mp3/title.mp3\"))\n\n        audio_clips_durations = [\n            float(ffmpeg.probe(f\"assets/temp/{reddit_id}/mp3/{i}.mp3\")[\"format\"][\"duration\"])\n            for i in range(number_of_clips)\n        ]\n        audio_clips_durations.insert(\n            0,\n            float(ffmpeg.probe(f\"assets/temp/{reddit_id}/mp3/title.mp3\")[\"format\"][\"duration\"]),\n        )\n    audio_concat = ffmpeg.concat(*audio_clips, a=1, v=0)\n    ffmpeg.output(\n        audio_concat, f\"assets/temp/{reddit_id}/audio.mp3\", **{\"b:a\": \"192k\"}\n    ).overwrite_output().run(quiet=True)\n\n    console.log(f\"[bold green] Video Will Be: {length} Seconds Long\")\n\n    screenshot_width = int((W * 45) // 100)\n    audio = ffmpeg.input(f\"assets/temp/{reddit_id}/audio.mp3\")\n    final_audio = merge_background_audio(audio, reddit_id)\n\n    image_clips = list()\n\n    Path(f\"assets/temp/{reddit_id}/png\").mkdir(parents=True, exist_ok=True)\n\n    # Credits to tim (beingbored)\n    # get the title_template image and draw a text in the middle part of it with the title of the thread\n    title_template = Image.open(\"assets/title_template.png\")\n\n    title = reddit_obj[\"thread_title\"]\n\n    title = name_normalize(title)\n\n    font_color = \"#000000\"\n    padding = 5\n\n    # create_fancy_thumbnail(image, text, text_color, padding\n    title_img = create_fancy_thumbnail(title_template, title, font_color, padding)\n\n    title_img.save(f\"assets/temp/{reddit_id}/png/title.png\")\n    image_clips.insert(\n        0,\n        ffmpeg.input(f\"assets/temp/{reddit_id}/png/title.png\")[\"v\"].filter(\n            \"scale\", screenshot_width, -1\n        ),\n    )\n\n    current_time = 0\n    if settings.config[\"settings\"][\"storymode\"]:\n        audio_clips_durations = [\n            float(\n                ffmpeg.probe(f\"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3\")[\"format\"][\"duration\"]\n            )\n            for i in range(number_of_clips)\n        ]\n        audio_clips_durations.insert(\n            0,\n            float(ffmpeg.probe(f\"assets/temp/{reddit_id}/mp3/title.mp3\")[\"format\"][\"duration\"]),\n        )\n        if settings.config[\"settings\"][\"storymodemethod\"] == 0:\n            image_clips.insert(\n                1,\n                ffmpeg.input(f\"assets/temp/{reddit_id}/png/story_content.png\").filter(\n                    \"scale\", screenshot_width, -1\n                ),\n            )\n            background_clip = background_clip.overlay(\n                image_clips[0],\n                enable=f\"between(t,{current_time},{current_time + audio_clips_durations[0]})\",\n                x=\"(main_w-overlay_w)/2\",\n                y=\"(main_h-overlay_h)/2\",\n            )\n            current_time += audio_clips_durations[0]\n        elif settings.config[\"settings\"][\"storymodemethod\"] == 1:\n            for i in track(range(0, number_of_clips + 1), \"Collecting the image files...\"):\n                image_clips.append(\n                    ffmpeg.input(f\"assets/temp/{reddit_id}/png/img{i}.png\")[\"v\"].filter(\n                        \"scale\", screenshot_width, -1\n                    )\n                )\n                background_clip = background_clip.overlay(\n                    image_clips[i],\n                    enable=f\"between(t,{current_time},{current_time + audio_clips_durations[i]})\",\n                    x=\"(main_w-overlay_w)/2\",\n                    y=\"(main_h-overlay_h)/2\",\n                )\n                current_time += audio_clips_durations[i]\n    else:\n        for i in range(0, number_of_clips + 1):\n            image_clips.append(\n                ffmpeg.input(f\"assets/temp/{reddit_id}/png/comment_{i}.png\")[\"v\"].filter(\n                    \"scale\", screenshot_width, -1\n                )\n            )\n            image_overlay = image_clips[i].filter(\"colorchannelmixer\", aa=opacity)\n            assert (\n                audio_clips_durations is not None\n            ), \"Please make a GitHub issue if you see this. Ping @JasonLovesDoggo on GitHub.\"\n            background_clip = background_clip.overlay(\n                image_overlay,\n                enable=f\"between(t,{current_time},{current_time + audio_clips_durations[i]})\",\n                x=\"(main_w-overlay_w)/2\",\n                y=\"(main_h-overlay_h)/2\",\n            )\n            current_time += audio_clips_durations[i]\n\n    title = extract_id(reddit_obj, \"thread_title\")\n    idx = extract_id(reddit_obj)\n    title_thumb = reddit_obj[\"thread_title\"]\n\n    filename = f\"{name_normalize(title)[:251]}\"\n    subreddit = settings.config[\"reddit\"][\"thread\"][\"subreddit\"]\n\n    if not exists(f\"./results/{subreddit}\"):\n        print_substep(\"The 'results' folder could not be found so it was automatically created.\")\n        os.makedirs(f\"./results/{subreddit}\")\n\n    if not exists(f\"./results/{subreddit}/OnlyTTS\") and allowOnlyTTSFolder:\n        print_substep(\"The 'OnlyTTS' folder could not be found so it was automatically created.\")\n        os.makedirs(f\"./results/{subreddit}/OnlyTTS\")\n\n    # create a thumbnail for the video\n    settingsbackground = settings.config[\"settings\"][\"background\"]\n\n    if settingsbackground[\"background_thumbnail\"]:\n        if not exists(f\"./results/{subreddit}/thumbnails\"):\n            print_substep(\n                \"The 'results/thumbnails' folder could not be found so it was automatically created.\"\n            )\n            os.makedirs(f\"./results/{subreddit}/thumbnails\")\n        # get the first file with the .png extension from assets/backgrounds and use it as a background for the thumbnail\n        first_image = next(\n            (file for file in os.listdir(\"assets/backgrounds\") if file.endswith(\".png\")),\n            None,\n        )\n        if first_image is None:\n            print_substep(\"No png files found in assets/backgrounds\", \"red\")\n\n        else:\n            font_family = settingsbackground[\"background_thumbnail_font_family\"]\n            font_size = settingsbackground[\"background_thumbnail_font_size\"]\n            font_color = settingsbackground[\"background_thumbnail_font_color\"]\n            thumbnail = Image.open(f\"assets/backgrounds/{first_image}\")\n            width, height = thumbnail.size\n            thumbnailSave = create_thumbnail(\n                thumbnail,\n                font_family,\n                font_size,\n                font_color,\n                width,\n                height,\n                title_thumb,\n            )\n            thumbnailSave.save(f\"./assets/temp/{reddit_id}/thumbnail.png\")\n            print_substep(f\"Thumbnail - Building Thumbnail in assets/temp/{reddit_id}/thumbnail.png\")\n\n    text = f\"Background by {background_config['video'][2]}\"\n    background_clip = ffmpeg.drawtext(\n        background_clip,\n        text=text,\n        x=f\"(w-text_w)\",\n        y=f\"(h-text_h)\",\n        fontsize=5,\n        fontcolor=\"White\",\n        fontfile=os.path.join(\"fonts\", \"Roboto-Regular.ttf\"),\n    )\n    background_clip = background_clip.filter(\"scale\", W, H)\n    print_step(\"Rendering the video 🎥\")\n    from tqdm import tqdm\n\n    pbar = tqdm(total=100, desc=\"Progress: \", bar_format=\"{l_bar}{bar}\", unit=\" %\")\n\n    def on_update_example(progress) -> None:\n        status = round(progress * 100, 2)\n        old_percentage = pbar.n\n        pbar.update(status - old_percentage)\n\n    defaultPath = f\"results/{subreddit}\"\n    with ProgressFfmpeg(length, on_update_example) as progress:\n        path = defaultPath + f\"/{filename}\"\n        path = (\n            path[:251] + \".mp4\"\n        )  # Prevent a error by limiting the path length, do not change this.\n        try:\n            ffmpeg.output(\n                background_clip,\n                final_audio,\n                path,\n                f=\"mp4\",\n                **{\n                    \"c:v\": \"h264_nvenc\",\n                    \"b:v\": \"20M\",\n                    \"b:a\": \"192k\",\n                    \"threads\": multiprocessing.cpu_count(),\n                },\n            ).overwrite_output().global_args(\"-progress\", progress.output_file.name).run(\n                quiet=True,\n                overwrite_output=True,\n                capture_stdout=False,\n                capture_stderr=False,\n            )\n        except ffmpeg.Error as e:\n            print(e.stderr.decode(\"utf8\"))\n            exit(1)\n    old_percentage = pbar.n\n    pbar.update(100 - old_percentage)\n    if allowOnlyTTSFolder:\n        path = defaultPath + f\"/OnlyTTS/{filename}\"\n        path = (\n            path[:251] + \".mp4\"\n        )  # Prevent a error by limiting the path length, do not change this.\n        print_step(\"Rendering the Only TTS Video 🎥\")\n        with ProgressFfmpeg(length, on_update_example) as progress:\n            try:\n                ffmpeg.output(\n                    background_clip,\n                    audio,\n                    path,\n                    f=\"mp4\",\n                    **{\n                        \"c:v\": \"h264_nvenc\",\n                        \"b:v\": \"20M\",\n                        \"b:a\": \"192k\",\n                        \"threads\": multiprocessing.cpu_count(),\n                    },\n                ).overwrite_output().global_args(\"-progress\", progress.output_file.name).run(\n                    quiet=True,\n                    overwrite_output=True,\n                    capture_stdout=False,\n                    capture_stderr=False,\n                )\n            except ffmpeg.Error as e:\n                print(e.stderr.decode(\"utf8\"))\n                exit(1)\n\n        old_percentage = pbar.n\n        pbar.update(100 - old_percentage)\n    pbar.close()\n    save_data(subreddit, filename + \".mp4\", title, idx, background_config[\"video\"][2])\n    print_step(\"Removing temporary files 🗑\")\n    cleanups = cleanup(reddit_id)\n    print_substep(f\"Removed {cleanups} temporary files 🗑\")\n    print_step(\"Done! 🎉 The video is in the results folder 📁\")\n"
  },
  {
    "path": "video_creation/screenshot_downloader.py",
    "content": "import json\nimport re\nfrom pathlib import Path\nfrom typing import Dict, Final\n\nimport translators\nfrom playwright.sync_api import ViewportSize, sync_playwright\nfrom rich.progress import track\n\nfrom utils import settings\nfrom utils.console import print_step, print_substep\nfrom utils.imagenarator import imagemaker\nfrom utils.playwright import clear_cookie_by_name\nfrom utils.videos import save_data\n\n__all__ = [\"get_screenshots_of_reddit_posts\"]\n\n\ndef get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int):\n    \"\"\"Downloads screenshots of reddit posts as seen on the web. Downloads to assets/temp/png\n\n    Args:\n        reddit_object (Dict): Reddit object received from reddit/subreddit.py\n        screenshot_num (int): Number of screenshots to download\n    \"\"\"\n    # settings values\n    W: Final[int] = int(settings.config[\"settings\"][\"resolution_w\"])\n    H: Final[int] = int(settings.config[\"settings\"][\"resolution_h\"])\n    lang: Final[str] = settings.config[\"reddit\"][\"thread\"][\"post_lang\"]\n    storymode: Final[bool] = settings.config[\"settings\"][\"storymode\"]\n\n    print_step(\"Downloading screenshots of reddit posts...\")\n    reddit_id = re.sub(r\"[^\\w\\s-]\", \"\", reddit_object[\"thread_id\"])\n    # ! Make sure the reddit screenshots folder exists\n    Path(f\"assets/temp/{reddit_id}/png\").mkdir(parents=True, exist_ok=True)\n\n    # set the theme and turn off non-essential cookies\n    if settings.config[\"settings\"][\"theme\"] == \"dark\":\n        cookie_file = open(\"./video_creation/data/cookie-dark-mode.json\", encoding=\"utf-8\")\n        bgcolor = (33, 33, 36, 255)\n        txtcolor = (240, 240, 240)\n        transparent = False\n    elif settings.config[\"settings\"][\"theme\"] == \"transparent\":\n        if storymode:\n            # Transparent theme\n            bgcolor = (0, 0, 0, 0)\n            txtcolor = (255, 255, 255)\n            transparent = True\n            cookie_file = open(\"./video_creation/data/cookie-dark-mode.json\", encoding=\"utf-8\")\n        else:\n            # Switch to dark theme\n            cookie_file = open(\"./video_creation/data/cookie-dark-mode.json\", encoding=\"utf-8\")\n            bgcolor = (33, 33, 36, 255)\n            txtcolor = (240, 240, 240)\n            transparent = False\n    else:\n        cookie_file = open(\"./video_creation/data/cookie-light-mode.json\", encoding=\"utf-8\")\n        bgcolor = (255, 255, 255, 255)\n        txtcolor = (0, 0, 0)\n        transparent = False\n\n    if storymode and settings.config[\"settings\"][\"storymodemethod\"] == 1:\n        print_substep(\"Generating images...\")\n        return imagemaker(\n            theme=bgcolor,\n            reddit_obj=reddit_object,\n            txtclr=txtcolor,\n            transparent=transparent,\n        )\n\n    screenshot_num: int\n    with sync_playwright() as p:\n        print_substep(\"Launching Headless Browser...\")\n\n        browser = p.chromium.launch(\n            headless=True\n        )  # headless=False will show the browser for debugging purposes\n        # Device scale factor (or dsf for short) allows us to increase the resolution of the screenshots\n        # When the dsf is 1, the width of the screenshot is 600 pixels\n        # so we need a dsf such that the width of the screenshot is greater than the final resolution of the video\n        dsf = (W // 600) + 1\n\n        context = browser.new_context(\n            locale=lang or \"en-CA,en;q=0.9\",\n            color_scheme=\"dark\",\n            viewport=ViewportSize(width=W, height=H),\n            device_scale_factor=dsf,\n            user_agent=f\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{browser.version}.0.0.0 Safari/537.36\",\n            extra_http_headers={\n                \"Dnt\": \"1\",\n                \"Sec-Ch-Ua\": '\"Not A(Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"',\n            },\n        )\n        cookies = json.load(cookie_file)\n        cookie_file.close()\n\n        context.add_cookies(cookies)  # load preference cookies\n\n        # Login to Reddit\n        print_substep(\"Logging in to Reddit...\")\n        page = context.new_page()\n        page.goto(\"https://www.reddit.com/login\", timeout=0)\n        page.set_viewport_size(ViewportSize(width=1920, height=1080))\n        page.wait_for_load_state()\n\n        page.locator(f'input[name=\"username\"]').fill(settings.config[\"reddit\"][\"creds\"][\"username\"])\n        page.locator(f'input[name=\"password\"]').fill(settings.config[\"reddit\"][\"creds\"][\"password\"])\n        page.get_by_role(\"button\", name=\"Log In\").click()\n        page.wait_for_timeout(5000)\n\n        login_error_div = page.locator(\".AnimatedForm__errorMessage\").first\n        if login_error_div.is_visible():\n\n            print_substep(\n                \"Your reddit credentials are incorrect! Please modify them accordingly in the config.toml file.\",\n                style=\"red\",\n            )\n            exit()\n        else:\n            pass\n\n        page.wait_for_load_state()\n        # Handle the redesign\n        # Check if the redesign optout cookie is set\n        if page.locator(\"#redesign-beta-optin-btn\").is_visible():\n            # Clear the redesign optout cookie\n            clear_cookie_by_name(context, \"redesign_optout\")\n            # Reload the page for the redesign to take effect\n            page.reload()\n        # Get the thread screenshot\n        page.goto(reddit_object[\"thread_url\"], timeout=0)\n        page.set_viewport_size(ViewportSize(width=W, height=H))\n        page.wait_for_load_state()\n        page.wait_for_timeout(5000)\n\n        if page.locator(\n            \"#t3_12hmbug > div > div._3xX726aBn29LDbsDtzr_6E._1Ap4F5maDtT1E1YuCiaO0r.D3IL3FD0RFy_mkKLPwL4 > div > div > button\"\n        ).is_visible():\n            # This means the post is NSFW and requires to click the proceed button.\n\n            print_substep(\"Post is NSFW. You are spicy...\")\n            page.locator(\n                \"#t3_12hmbug > div > div._3xX726aBn29LDbsDtzr_6E._1Ap4F5maDtT1E1YuCiaO0r.D3IL3FD0RFy_mkKLPwL4 > div > div > button\"\n            ).click()\n            page.wait_for_load_state()  # Wait for page to fully load\n\n            # translate code\n        if page.locator(\n            \"#SHORTCUT_FOCUSABLE_DIV > div:nth-child(7) > div > div > div > header > div > div._1m0iFpls1wkPZJVo38-LSh > button > i\"\n        ).is_visible():\n            page.locator(\n                \"#SHORTCUT_FOCUSABLE_DIV > div:nth-child(7) > div > div > div > header > div > div._1m0iFpls1wkPZJVo38-LSh > button > i\"\n            ).click()  # Interest popup is showing, this code will close it\n\n        if lang:\n            print_substep(\"Translating post...\")\n            texts_in_tl = translators.translate_text(\n                reddit_object[\"thread_title\"],\n                to_language=lang,\n                translator=\"google\",\n            )\n\n            page.evaluate(\n                \"tl_content => document.querySelector('[data-adclicklocation=\\\"title\\\"] > div > div > h1').textContent = tl_content\",\n                texts_in_tl,\n            )\n        else:\n            print_substep(\"Skipping translation...\")\n\n        postcontentpath = f\"assets/temp/{reddit_id}/png/title.png\"\n        try:\n            if settings.config[\"settings\"][\"zoom\"] != 1:\n                # store zoom settings\n                zoom = settings.config[\"settings\"][\"zoom\"]\n                # zoom the body of the page\n                page.evaluate(\"document.body.style.zoom=\" + str(zoom))\n                # as zooming the body doesn't change the properties of the divs, we need to adjust for the zoom\n                location = page.locator('[data-test-id=\"post-content\"]').bounding_box()\n                for i in location:\n                    location[i] = float(\"{:.2f}\".format(location[i] * zoom))\n                page.screenshot(clip=location, path=postcontentpath)\n            else:\n                page.locator('[data-test-id=\"post-content\"]').screenshot(path=postcontentpath)\n        except Exception as e:\n            print_substep(\"Something went wrong!\", style=\"red\")\n            resp = input(\n                \"Something went wrong with making the screenshots! Do you want to skip the post? (y/n) \"\n            )\n\n            if resp.casefold().startswith(\"y\"):\n                save_data(\"\", \"\", \"skipped\", reddit_id, \"\")\n                print_substep(\n                    \"The post is successfully skipped! You can now restart the program and this post will skipped.\",\n                    \"green\",\n                )\n\n            resp = input(\"Do you want the error traceback for debugging purposes? (y/n)\")\n            if not resp.casefold().startswith(\"y\"):\n                exit()\n\n            raise e\n\n        if storymode:\n            page.locator('[data-click-id=\"text\"]').first.screenshot(\n                path=f\"assets/temp/{reddit_id}/png/story_content.png\"\n            )\n        else:\n            for idx, comment in enumerate(\n                track(\n                    reddit_object[\"comments\"][:screenshot_num],\n                    \"Downloading screenshots...\",\n                )\n            ):\n                # Stop if we have reached the screenshot_num\n                if idx >= screenshot_num:\n                    break\n\n                if page.locator('[data-testid=\"content-gate\"]').is_visible():\n                    page.locator('[data-testid=\"content-gate\"] button').click()\n\n                page.goto(f\"https://new.reddit.com/{comment['comment_url']}\")\n\n                # translate code\n\n                if settings.config[\"reddit\"][\"thread\"][\"post_lang\"]:\n                    comment_tl = translators.translate_text(\n                        comment[\"comment_body\"],\n                        translator=\"google\",\n                        to_language=settings.config[\"reddit\"][\"thread\"][\"post_lang\"],\n                    )\n                    page.evaluate(\n                        '([tl_content, tl_id]) => document.querySelector(`#t1_${tl_id} > div:nth-child(2) > div > div[data-testid=\"comment\"] > div`).textContent = tl_content',\n                        [comment_tl, comment[\"comment_id\"]],\n                    )\n                try:\n                    if settings.config[\"settings\"][\"zoom\"] != 1:\n                        # store zoom settings\n                        zoom = settings.config[\"settings\"][\"zoom\"]\n                        # zoom the body of the page\n                        page.evaluate(\"document.body.style.zoom=\" + str(zoom))\n                        # scroll comment into view\n                        page.locator(f\"#t1_{comment['comment_id']}\").scroll_into_view_if_needed()\n                        # as zooming the body doesn't change the properties of the divs, we need to adjust for the zoom\n                        location = page.locator(f\"#t1_{comment['comment_id']}\").bounding_box()\n                        for i in location:\n                            location[i] = float(\"{:.2f}\".format(location[i] * zoom))\n                        page.screenshot(\n                            clip=location,\n                            path=f\"assets/temp/{reddit_id}/png/comment_{idx}.png\",\n                        )\n                    else:\n                        page.locator(f\"#t1_{comment['comment_id']}\").screenshot(\n                            path=f\"assets/temp/{reddit_id}/png/comment_{idx}.png\"\n                        )\n                except TimeoutError:\n                    del reddit_object[\"comments\"]\n                    screenshot_num += 1\n                    print(\"TimeoutError: Skipping screenshot...\")\n                    continue\n\n        # close browser instance when we are done using it\n        browser.close()\n\n    print_substep(\"Screenshots downloaded Successfully.\", style=\"bold green\")\n"
  },
  {
    "path": "video_creation/voices.py",
    "content": "from typing import Tuple\n\nfrom rich.console import Console\n\nfrom TTS.aws_polly import AWSPolly\nfrom TTS.elevenlabs import elevenlabs\nfrom TTS.engine_wrapper import TTSEngine\nfrom TTS.GTTS import GTTS\nfrom TTS.openai_tts import OpenAITTS\nfrom TTS.pyttsx import pyttsx\nfrom TTS.streamlabs_polly import StreamlabsPolly\nfrom TTS.TikTok import TikTok\nfrom utils import settings\nfrom utils.console import print_step, print_table\n\nconsole = Console()\n\nTTSProviders = {\n    \"GoogleTranslate\": GTTS,\n    \"AWSPolly\": AWSPolly,\n    \"StreamlabsPolly\": StreamlabsPolly,\n    \"TikTok\": TikTok,\n    \"pyttsx\": pyttsx,\n    \"ElevenLabs\": elevenlabs,\n    \"OpenAI\": OpenAITTS,\n}\n\n\ndef save_text_to_mp3(reddit_obj) -> Tuple[int, int]:\n    \"\"\"Saves text to MP3 files.\n\n    Args:\n        reddit_obj (): Reddit object received from reddit API in reddit/subreddit.py\n\n    Returns:\n        tuple[int,int]: (total length of the audio, the number of comments audio was generated for)\n    \"\"\"\n\n    voice = settings.config[\"settings\"][\"tts\"][\"voice_choice\"]\n    if str(voice).casefold() in map(lambda _: _.casefold(), TTSProviders):\n        text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, voice), reddit_obj)\n    else:\n        while True:\n            print_step(\"Please choose one of the following TTS providers: \")\n            print_table(TTSProviders)\n            choice = input(\"\\n\")\n            if choice.casefold() in map(lambda _: _.casefold(), TTSProviders):\n                break\n            print(\"Unknown Choice\")\n        text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, choice), reddit_obj)\n    return text_to_mp3.run()\n\n\ndef get_case_insensitive_key_value(input_dict, key):\n    return next(\n        (value for dict_key, value in input_dict.items() if dict_key.lower() == key.lower()),\n        None,\n    )\n"
  }
]