diff --git a/.config.template.toml b/.config.template.toml new file mode 100644 index 0000000..18f1b00 --- /dev/null +++ b/.config.template.toml @@ -0,0 +1,45 @@ +[reddit.creds] +client_id = { optional = false, nmin = 12, nmax = 30, explanation = "the ID of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The ID should be over 12 and under 30 characters, double check your input." } +client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "the SECRET of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The secret should be over 20 and under 40 characters, double check your input." } +username = { optional = false, nmin = 3, nmax = 20, explanation = "the username of your reddit account", example = "JasonLovesDoggo", regex = "^[-_0-9a-zA-Z]+$", oob_error = "A username HAS to be between 3 and 20 characters" } +password = { optional = false, nmin = 8, explanation = "the password of your reddit account", example = "fFAGRNJru1FTz70BzhT3Zg", oob_error = "Password too short" } +2fa = { optional = true, type = "bool", options = [true, + false, +], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true } + + +[reddit.thread] +random = { optional = true, options = [true, + false, +], default = false, type = "bool", explanation = "If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'", example = "True" } +subreddit = { optional = false, regex = "[_0-9a-zA-Z]+$", nmin = 3, explanation = "what subreddit to pull posts from, the name of the sub, not the URL", example = "AskReddit", oob_error = "A subreddit name HAS to be between 3 and 20 characters" } +post_id = { optional = true, default = "", regex = "^((?!://|://)[+a-zA-Z])*$", explanation = "Used if you want to use a specific post.", example = "urdtfx" } +max_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" } +post_lang = { default = "", optional = true, explanation = "The language you would like to translate to.", example = "es-cr" } +min_comments = { default = 20, optional = false, nmin = 15, 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" } +[settings] +allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, + false, +], explanation = "Whether to allow NSFW content, True or False" } +theme = { optional = false, default = "dark", example = "light", options = ["dark", + "light", +], explanation = "sets the Reddit theme, either LIGHT or DARK" } +times_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." } +opacity = { 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" } +storymode = { optional = true, type = "bool", default = false, example = false, options = [true, + false, +], explanation = "not yet implemented" } + +[settings.background] +background_choice = { optional = true, default = "minecraft", example = "minecraft", options = ["minecraft", "gta", "rocket-league", "motor-gta", ""], explanation = "Sets the background for the video" } +#background_audio = { optional = true, type = "bool", default = false, example = false, options = [true, +# false, +#], explaination="Sets a audio to play in the background (put a background.mp3 file in the assets/backgrounds directory for it to be used.)" } +#background_audio_volume = { optional = true, type = "float", default = 0.3, example = 0.1, explanation="Sets the volume of the background audio. only used if the background_audio is also set to true" } + + +[settings.tts] +choice = { optional = false, default = "", options = ["streamlabspolly", "tiktok", "googletranslate", "awspolly", ], example = "streamlabspolly", explanation = "The backend used for TTS generation. This can be left blank and you will be prompted to choose at runtime." } +aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for AWS Polly" } +streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" } +tiktok_voice = { optional = false, default = "en_us_006", example = "en_us_006", explanation = "The voice used for TikTok TTS" } diff --git a/.env.template b/.env.template deleted file mode 100644 index 7cf7cc2..0000000 --- a/.env.template +++ /dev/null @@ -1,86 +0,0 @@ - -REDDIT_CLIENT_ID="" #fFAGRNJru1FTz70BzhT3Zg -#EXPLANATION the ID of your Reddit app of SCRIPT type -#RANGE 12:30 -#MATCH_REGEX [-a-zA-Z0-9._~+/]+=*$ -#OOB_ERROR The ID should be over 12 and under 30 characters, double check your input. - -REDDIT_CLIENT_SECRET="" #fFAGRNJru1FTz70BzhT3Zg -#EXPLANATION the SECRET of your Reddit app of SCRIPT type -#RANGE 20:40 -#MATCH_REGEX [-a-zA-Z0-9._~+/]+=*$ -#OOB_ERROR The secret should be over 20 and under 40 characters, double check your input. - -REDDIT_USERNAME="" #asdfghjkl -#EXPLANATION the username of your reddit account -#RANGE 3:20 -#MATCH_REGEX [_0-9a-zA-Z]+$ -#OOB_ERROR A username HAS to be between 3 and 20 characters - -REDDIT_PASSWORD="" #fFAGRNJru1FTz70BzhT3Zg -#EXPLANATION the password of your reddit account -#RANGE 8:None -#OOB_ERROR Password too short - -#OPTIONAL -RANDOM_THREAD="no" -# If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: "no" - -REDDIT_2FA="" #no -#MATCH_REGEX ^(yes|no) -#EXPLANATION Whether you have Reddit 2FA enabled, Valid options are "yes" and "no" - -SUBREDDIT="AskReddit" -#EXPLANATION what subreddit to pull posts from, the name of the sub, not the URL -#RANGE 3:20 -#MATCH_REGEX [_0-9a-zA-Z]+$ -#OOB_ERROR A subreddit name HAS to be between 3 and 20 characters - -ALLOW_NSFW="False" -#EXPLANATION Whether to allow NSFW content, True or False -#MATCH_REGEX ^(True|False)$ - -POST_ID="" -#MATCH_REGEX ^((?!://|://).)*$ -#EXPLANATION Used if you want to use a specific post. example of one is urdtfx - -THEME="LIGHT" #dark -#EXPLANATION sets the Reddit theme, either LIGHT or DARK -#MATCH_REGEX ^(dark|light|DARK|LIGHT)$ - -TIMES_TO_RUN="" #2 -#EXPLANATION used if you want to run multiple times. set to an int e.g. 4 or 29 and leave blank for 1 - -MAX_COMMENT_LENGTH="500" #500 -#EXPLANATION max number of characters a comment can have. default is 500 -#RANGE 0:10000 -#MATCH_TYPE int -#OOB_ERROR the max comment length should be between 0 and 10000 - -OPACITY="1" #.8 -#EXPLANATION Sets the opacity of the comments when overlayed over the background -#RANGE 0:1 -#MATCH_TYPE float -#OOB_ERROR The opacity HAS to be between 0 and 1 - -# If you want to translate the comments to another language, set the language code here. -# If empty, no translation will be done. -POSTLANG="" -#EXPLANATION Activates the translation feature, set the language code for translate or leave blank - -TTSCHOICE="Polly" -#EXPLANATION the backend used for TTS. Without anything specified, the user will be prompted to choose one. -# IMPORTANT NOTE: if you use translate, you need to set this to googletranslate or tiktok and use custom voice in your language - -STREAMLABS_VOICE="Joanna" -#EXPLANATION Sets the voice for the Streamlabs Polly TTS Engine. Check the file for more information on different voices. - -AWS_VOICE="Joanna" -#EXPLANATION Sets the voice for the AWS Polly TTS Engine. Check the file for more information on different voices. - -TIKTOK_VOICE="en_us_006" -#EXPLANATION Sets the voice for the TikTok TTS Engine. Check the file for more information on different voices. - -#OPTIONAL -STORYMODE="False" -# IN-PROGRESS - not yet implemented diff --git a/.github/workflows/autoblack.yml b/.github/workflows/autoblack.yml new file mode 100644 index 0000000..ba9cc36 --- /dev/null +++ b/.github/workflows/autoblack.yml @@ -0,0 +1,32 @@ +# GitHub Action that uses Black to reformat the Python code in an incoming pull request. +# If all Python code in the pull request is compliant with Black then this Action does nothing. +# Othewrwise, Black is run and its changes are committed back to the incoming pull request. +# https://github.com/cclauss/autoblack + +name: autoblack +on: + push: + branches: ["master"] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.7 + uses: actions/setup-python@v1 + with: + python-version: 3.9 + - name: Install Black + run: pip install black + - name: Run black --check . + run: black --check . + - name: If needed, commit black changes to the pull request + if: failure() + run: | + black . --line-length 101 + git config --global user.name 'autoblack' + git config --global user.email 'jasoncameron.all@gmail.com' + git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY + git checkout $GITHUB_HEAD_REF + git commit -am "fixup: Format Python code with Black" + git push diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 238dad4..ec78b1a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -14,10 +14,10 @@ name: "CodeQL" on: push: - branches: [ "master" ] + branches: [ "master", "develop" ] pull_request: # The branches below must be a subset of the branches above - branches: [ "master" ] + branches: [ "master", "develop" ] schedule: - cron: '16 14 * * 3' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f95531f --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,12 @@ +name: Lint + +on: [pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: psf/black@stable + with: + options: "--line-length 101" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 40f2245..3db14aa 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -5,36 +5,46 @@ on: - cron: '0 0 * * *' jobs: + stale: runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write steps: - - uses: actions/stale@main + - uses: actions/stale@v4 id: stale-issue name: stale-issue with: + # general settings + repo-token: ${{ secrets.GITHUB_TOKEN }} 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.' close-issue-message: 'Issue closed due to being stale. Please reopen if issue persists in latest version.' - days-before-stale: 7 - days-before-close: 10 + days-before-stale: 6 + days-before-close: 12 stale-issue-label: 'stale' close-issue-label: 'outdated' exempt-issue-labels: 'enhancement,keep,blocked' exempt-all-issue-milestones: true operations-per-run: 300 remove-stale-when-updated: true - - - uses: actions/stale@main + ascending: true + #debug-only: true + + - uses: actions/stale@v4 id: stale-pr name: stale-pr with: + # general settings + repo-token: ${{ secrets.GITHUB_TOKEN }} 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.' close-pr-message: 'Pull request closed due to being stale.' - days-before-stale: 7 - days-before-close: 10 + days-before-stale: 10 + days-before-close: 20 close-pr-label: 'outdated' stale-pr-label: 'stale' exempt-pr-labels: 'keep,blocked,before next release,after next release' exempt-all-pr-milestones: true operations-per-run: 300 remove-stale-when-updated: true - + #debug-only: true diff --git a/.gitignore b/.gitignore index 4ee3693..793db5d 100644 --- a/.gitignore +++ b/.gitignore @@ -241,3 +241,5 @@ reddit-bot-351418-5560ebc49cac.json *.pyc video_creation/data/videos.json video_creation/data/envvars.txt + +config.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3e858d..ca1c7cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,7 +111,16 @@ When making your PR, follow these guidelines: - Your branch has a base of _develop_, **not** _master_ - You are merging your branch into the _develop_ branch -- You link any issues that are resolved or fixed by your changes. (this is done by typing "Fixes #\") in your pull request. +- You link any issues that are resolved or fixed by your changes. (this is done by typing "Fixes #\") in your pull request +- Where possible, you have used `git pull --rebase`, to avoid creating unnecessary merge commits +- You have meaningful commits, and if possible, follow the commit style guide of `type: explanation` +- Here are the commit types: + - **feat** - a new feature + - **fix** - a bug fix + - **docs** - a change to documentation / commenting + - **style** - formatting changes - does not impact code + - **refactor** - refactored code + - **chore** - updating configs, workflows etc - does not impact code ### Improving The Documentation diff --git a/GUI/index.html b/GUI/index.html new file mode 100644 index 0000000..807c9e7 --- /dev/null +++ b/GUI/index.html @@ -0,0 +1,281 @@ + + + + + + RedditVideoMakerBot + + + + + + + + + +
+ +
+ +
+
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + diff --git a/README.md b/README.md index dc6237c..8a9e205 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,21 @@ The only original thing being done is the editing and gathering of all materials 1. Clone this repository 2. Run `pip install -r requirements.txt` -3. Run `playwright install` and `playwright install-deps`. (if this fails try adding python -m to the front of the command) +3. Run `python -m playwright install` and `python -m playwright install-deps` + +**EXPERIMENTAL!!!!** + +On MacOS and Linux (debian, arch, fedora and centos, and based on those), you can run an install script that will automatically install steps 1 to 3. (requires bash) + +`bash <(curl -sL https://raw.githubusercontent.com/elebumm/RedditVideoMakerBot/master/install.sh)` + +This can also be used to update the installation 4. Run `python main.py` - required\*\*), visit [the Reddit Apps page.](https://www.reddit.com/prefs/apps) TL;DR set up an app that is a "script". -5. Enjoy 😎 +5. Visit [the Reddit Apps page.](https://www.reddit.com/prefs/apps), and set up an app that is a "script". +6. The bot will ask you to fill in your details to connect to the Reddit API, and configure the bot to your liking +7. Enjoy 😎 +8. 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. (Note if you got an error installing or running the bot try first rerunning the command with a three after the name e.g. python3 or pip3) @@ -73,11 +83,11 @@ Please read our [contributing guidelines](CONTRIBUTING.md) for more detailed inf Elebumm (Lewis#6305) - https://github.com/elebumm (Founder) -Jason (JasonLovesDoggo#1904) - https://github.com/JasonLovesDoggo +Jason (JasonLovesDoggo#1904) - https://github.com/JasonLovesDoggo (Maintainer) CallumIO (c.#6837) - https://github.com/CallumIO -HarryDaDev (hrvyy#9677) - https://github.com/ImmaHarry +Verq (Verq#2338) - https://github.com/CordlessCoder LukaHietala (Pix.#0001) - https://github.com/LukaHietala diff --git a/TTS/GTTS.py b/TTS/GTTS.py index 992eeb5..31e29df 100644 --- a/TTS/GTTS.py +++ b/TTS/GTTS.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import random -import os +from utils import settings from gtts import gTTS max_chars = 0 @@ -12,7 +12,11 @@ class GTTS: self.voices = [] def run(self, text, filepath): - tts = gTTS(text=text, lang=os.getenv("POSTLANG") or "en", slow=False) + tts = gTTS( + text=text, + lang=settings.config["reddit"]["thread"]["post_lang"] or "en", + slow=False, + ) tts.save(filepath) def randomvoice(self): diff --git a/TTS/TikTok.py b/TTS/TikTok.py index 91ba526..743118c 100644 --- a/TTS/TikTok.py +++ b/TTS/TikTok.py @@ -1,5 +1,5 @@ import base64 -import os +from utils import settings import random import requests from requests.adapters import HTTPAdapter, Retry @@ -75,7 +75,10 @@ class TikTok: # TikTok Text-to-Speech Wrapper voice = ( self.randomvoice() if random_voice - else (os.getenv("TIKTOK_VOICE") or random.choice(self.voices["human"])) + else ( + settings.config["settings"]["tts"]["tiktok_voice"] + or random.choice(self.voices["human"]) + ) ) try: r = requests.post(f"{self.URI_BASE}{voice}&req_text={text}&speaker_map_type=0") diff --git a/TTS/aws_polly.py b/TTS/aws_polly.py index 703aa6a..efd762b 100644 --- a/TTS/aws_polly.py +++ b/TTS/aws_polly.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 from boto3 import Session -from botocore.exceptions import BotoCoreError, ClientError +from botocore.exceptions import BotoCoreError, ClientError, ProfileNotFound import sys -import os +from utils import settings import random voices = [ @@ -30,36 +30,46 @@ class AWSPolly: self.voices = voices def run(self, text, filepath, random_voice: bool = False): - session = Session(profile_name="polly") - polly = session.client("polly") - if random_voice: - voice = self.randomvoice() - else: - if not os.getenv("VOICE"): - return ValueError( - f"Please set the environment variable VOICE to a valid voice. options are: {voices}" - ) - voice = str(os.getenv("AWS_VOICE")).capitalize() try: - # Request speech synthesis - response = polly.synthesize_speech( - Text=text, OutputFormat="mp3", VoiceId=voice, Engine="neural" - ) - except (BotoCoreError, ClientError) as error: - # The service returned an error, exit gracefully - print(error) - sys.exit(-1) + session = Session(profile_name="polly") + polly = session.client("polly") + if random_voice: + voice = self.randomvoice() + else: + if not settings.config["settings"]["tts"]["aws_polly_voice"]: + raise ValueError( + f"Please set the TOML variable AWS_VOICE to a valid voice. options are: {voices}" + ) + voice = str(settings.config["settings"]["tts"]["aws_polly_voice"]).capitalize() + try: + # Request speech synthesis + response = polly.synthesize_speech( + Text=text, OutputFormat="mp3", VoiceId=voice, Engine="neural" + ) + except (BotoCoreError, ClientError) as error: + # The service returned an error, exit gracefully + print(error) + sys.exit(-1) - # Access the audio stream from the response - if "AudioStream" in response: - file = open(filepath, "wb") - file.write(response["AudioStream"].read()) - file.close() - # print_substep(f"Saved Text {idx} to MP3 files successfully.", style="bold green") + # Access the audio stream from the response + if "AudioStream" in response: + file = open(filepath, "wb") + file.write(response["AudioStream"].read()) + file.close() + # print_substep(f"Saved Text {idx} to MP3 files successfully.", style="bold green") - else: - # The response didn't contain audio data, exit gracefully - print("Could not stream audio") + else: + # The response didn't contain audio data, exit gracefully + print("Could not stream audio") + sys.exit(-1) + except ProfileNotFound: + print("You need to install the AWS CLI and configure your profile") + print( + """ + Linux: https://docs.aws.amazon.com/polly/latest/dg/setup-aws-cli.html + Windows: https://docs.aws.amazon.com/polly/latest/dg/install-voice-plugin2.html + """ + ) sys.exit(-1) def randomvoice(self): diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index bbe4e9a..c5ed714 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -2,13 +2,16 @@ from pathlib import Path from typing import Tuple import re -from os import getenv -from mutagen.mp3 import MP3 + +# import sox +# from mutagen import MutagenError +# from mutagen.mp3 import MP3, HeaderNotFoundError import translators as ts from rich.progress import track from moviepy.editor import AudioFileClip, CompositeAudioClip, concatenate_audioclips from utils.console import print_step, print_substep from utils.voice import sanitize_text +from utils import settings DEFUALT_MAX_LENGTH: int = 50 # video length variable @@ -44,7 +47,7 @@ class TTSEngine: Path(self.path).mkdir(parents=True, exist_ok=True) - # This file needs to be removed in case this post does not use post text, so that it wont appear in the final video + # This file needs to be removed in case this post does not use post text, so that it won't appear in the final video try: Path(f"{self.path}/posttext.mp3").unlink() except OSError: @@ -53,7 +56,10 @@ class TTSEngine: print_step("Saving Text to MP3 files...") self.call_tts("title", self.reddit_object["thread_title"]) - if self.reddit_object["thread_post"] != "" and getenv("STORYMODE", "").casefold() == "true": + if ( + self.reddit_object["thread_post"] != "" + and settings.config["settings"]["storymode"] == True + ): self.call_tts("posttext", self.reddit_object["thread_post"]) idx = None @@ -61,41 +67,64 @@ class TTSEngine: # ! Stop creating mp3 files if the length is greater than max length. if self.length > self.max_length: break - if not self.tts_module.max_chars: + if ( + len(comment["comment_body"]) > self.tts_module.max_chars + ): # Split the comment if it is too long + self.split_post(comment["comment_body"], idx) # Split the comment + else: # If the comment is not too long, just call the tts engine self.call_tts(f"{idx}", comment["comment_body"]) - else: - self.split_post(comment["comment_body"], idx) print_substep("Saved Text to MP3 files successfully.", style="bold green") return self.length, idx - def split_post(self, text: str, idx: int) -> str: + def split_post(self, text: str, idx: int): split_files = [] split_text = [ x.group().strip() - for x in re.finditer(rf" *((.{{0,{self.tts_module.max_chars}}})(\.|.$))", text) + for x in re.finditer( + r" *(((.|\n){0," + str(self.tts_module.max_chars) + "})(\.|.$))", text + ) ] - - idy = None + offset = 0 for idy, text_cut in enumerate(split_text): # print(f"{idx}-{idy}: {text_cut}\n") - self.call_tts(f"{idx}-{idy}.part", text_cut) - split_files.append(AudioFileClip(f"{self.path}/{idx}-{idy}.part.mp3")) + if not text_cut or text_cut.isspace(): + offset += 1 + continue + + self.call_tts(f"{idx}-{idy - offset}.part", text_cut) + split_files.append(AudioFileClip(f"{self.path}/{idx}-{idy - offset}.part.mp3")) + CompositeAudioClip([concatenate_audioclips(split_files)]).write_audiofile( f"{self.path}/{idx}.mp3", fps=44100, verbose=False, logger=None ) - for i in range(0, idy + 1): - # print(f"Cleaning up {self.path}/{idx}-{i}.part.mp3") - Path(f"{self.path}/{idx}-{i}.part.mp3").unlink() + for i in split_files: + name = i.filename + i.close() + Path(name).unlink() + + # for i in range(0, idy + 1): + # print(f"Cleaning up {self.path}/{idx}-{i}.part.mp3") + + # Path(f"{self.path}/{idx}-{i}.part.mp3").unlink() def call_tts(self, filename: str, text: str): self.tts_module.run(text=process_text(text), filepath=f"{self.path}/{filename}.mp3") - self.length += MP3(f"{self.path}/{filename}.mp3").info.length + # try: + # self.length += MP3(f"{self.path}/{filename}.mp3").info.length + # except (MutagenError, HeaderNotFoundError): + # self.length += sox.file_info.duration(f"{self.path}/{filename}.mp3") + try: + clip = AudioFileClip(f"{self.path}/{filename}.mp3") + self.length += clip.duration + clip.close() + except: + self.length = 0 def process_text(text: str): - lang = getenv("POSTLANG", "") + lang = settings.config["reddit"]["thread"]["post_lang"] new_text = sanitize_text(text) if lang: print_substep("Translating Text...") diff --git a/TTS/streamlabs_polly.py b/TTS/streamlabs_polly.py index 41fe269..75c4f49 100644 --- a/TTS/streamlabs_polly.py +++ b/TTS/streamlabs_polly.py @@ -1,7 +1,8 @@ import random -import os import requests from requests.exceptions import JSONDecodeError +from utils import settings +from utils.voice import check_ratelimit voices = [ "Brian", @@ -35,26 +36,27 @@ class StreamlabsPolly: if random_voice: voice = self.randomvoice() else: - if not os.getenv("VOICE"): - return ValueError( - f"Please set the environment variable VOICE to a valid voice. options are: {voices}" + if not settings.config["settings"]["tts"]["streamlabs_polly_voice"]: + raise ValueError( + f"Please set the config variable STREAMLABS_POLLY_VOICE to a valid voice. options are: {voices}" ) - voice = str(os.getenv("STREAMLABS_VOICE")).capitalize() + voice = str(settings.config["settings"]["tts"]["streamlabs_polly_voice"]).capitalize() body = {"voice": voice, "text": text, "service": "polly"} response = requests.post(self.url, data=body) - try: - voice_data = requests.get(response.json()["speak_url"]) - with open(filepath, "wb") as f: - f.write(voice_data.content) - except (KeyError, JSONDecodeError): + if not check_ratelimit(response): + self.run(text, filepath, random_voice) + + else: try: - if response.json()["error"] == "No text specified!": - raise ValueError("Please specify a text to convert to speech.") + voice_data = requests.get(response.json()["speak_url"]) + with open(filepath, "wb") as f: + f.write(voice_data.content) except (KeyError, JSONDecodeError): - print("Error occurred calling Streamlabs Polly") + try: + if response.json()["error"] == "No text specified!": + raise ValueError("Please specify a text to convert to speech.") + except (KeyError, JSONDecodeError): + print("Error occurred calling Streamlabs Polly") def randomvoice(self): return random.choice(self.voices) - - -# StreamlabsPolly().run(text=str('hi hi ' * 92)[1:], filepath='hello.mp3', random_voice=True) diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..782069d --- /dev/null +++ b/install.sh @@ -0,0 +1,223 @@ +#!/bin/bash + +# If the install fails, then print an error and exit. +function install_fail() { + echo "Installation failed" + exit 1 +} + +# This is the help fuction. It helps users withe the options +function Help(){ + echo "Usage: install.sh [option]" + echo "Options:" + echo " -h: Show this help message and exit" + echo " -d: Install only dependencies" + echo " -p: Install only python dependencies (including playwright)" + echo " -b: Install just the bot" + echo " -l: Install the bot and the python dependencies" +} + +# Options +while getopts ":hydpbl" option; do + case $option in + # -h, prints help message + h) + Help exit 0;; + # -y, assumes yes + y) + ASSUME_YES=1;; + # -d install only dependencies + d) + DEPS_ONLY=1;; + # -p install only python dependencies + p) + PYTHON_ONLY=1;; + b) + JUST_BOT=1;; + l) + BOT_AND_PYTHON=1;; + # if a bad argument is given, then throw an error + \?) + echo "Invalid option: -$OPTARG" >&2 Help exit 1;; + :) + echo "Option -$OPTARG requires an argument." >&2 Help exit 1;; + esac +done + +# Install dependencies for MacOS +function install_macos(){ + # Check if homebrew is installed + if [ ! command -v brew &> /dev/null ]; then + echo "Installing Homebrew" + # if it's is not installed, then install it in a NONINTERACTIVE way + NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" + # Check for what arcitecture, so you can place path. + if [[ "uname -m" == "x86_64" ]]; then + echo "export PATH=/usr/local/bin:$PATH" >> ~/.bash_profile && source ~/.bash_profile + fi + # If not + else + # Print that it's already installed + echo "Homebrew is already installed" + fi + # Install the required packages + echo "Installing required Packages" + brew install python@3.10 tcl-tk python-tk +} + +# Function to install for arch (and other forks like manjaro) +function install_arch(){ + echo "Installing required packages" + sudo pacman -S --needed python3 tk git && python3 -m ensurepip || install_fail +} + +# Function to install for debian (and ubuntu) +function install_deb(){ + echo "Installing required packages" + sudo apt install python3 python3-dev python3-tk python3-pip git || install_fail +} + +# Function to install for fedora (and other forks) +function install_fedora(){ + echo "Installing required packages" + sudo dnf install python3 python3-tkinter python3-pip git python3-devel || install_fail +} + +# Function to install for centos (and other forks based on it) +function install_centos(){ + echo "Installing required packages" + sudo yum install -y python3 || install_fail + sudo yum install -y python3-tkinter epel-release python3-pip git || install_fail +} + +function get_the_bot(){ + echo "Downloading the bot" + git clone https://github.com/elebumm/RedditVideoMakerBot.git +} + +#install python dependencies +function install_python_dep(){ + # tell the user that the script is going to install the python dependencies + echo "Installing python dependencies" + # cd into the directory + cd RedditVideoMakerBot + # install the dependencies + pip3 install -r requirements.txt + # cd out + cd .. +} + +# install playwright function +function install_playwright(){ + # tell the user that the script is going to install playwright + echo "Installing playwright" + # cd into the directory where the script is downloaded + cd RedditVideoMakerBot + # run the install script + python3 -m playwright install + python3 -m playwright install-deps + # give a note + 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" + if [ -x "$(command -v pacman)" ]; then + 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" + fi + cd .. +} + +# Install depndencies +function install_deps(){ + # if the platform is mac, install macos + if [ "$(uname)" == "Darwin" ]; then + install_macos || install_fail + # if pacman is found + elif [ -x "$(command -v pacman)" ]; then + # install for arch + install_arch || install_fail + # if apt-get is found + elif [ -x "$(command -v apt-get)" ]; then + # install fro debian + install_deb || install_fail + # if dnf is found + elif [ -x "$(command -v dnf)" ]; then + # install for fedora + install_fedora || install_fail + # if yum is found + elif [ -x "$(command -v yum)" ]; then + # install for centos + install_centos || install_fail + # else + else + # print an error message and exit + 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 +https://github.com/elebumm/RedditVideoMakerBot" + exit 1 + fi +} + +# Main function +function install_main(){ + # Print that are installing + echo "Installing..." + # if -y (assume yes) continue + if [[ ASSUME_YES -eq 1 ]]; then + echo "Assuming yes" + # else, ask if they want to continue + else + echo "Continue? (y/n)" + read answer + # if the answer is not yes, then exit + if [ "$answer" != "y" ]; then + echo "Aborting" + exit 1 + fi + fi + # if the -d (only dependencies) options is selected install just the dependencies + if [[ DEPS_ONLY -eq 1 ]]; then + echo "Installing only dependencies" + install_deps + elif [[ PYTHON_ONLY -eq 1 ]]; then + # if the -p (only python dependencies) options is selected install just the python dependencies and playwright + echo "Installing only python dependencies" + install_python_dep + install_playwright + # if the -b (only the bot) options is selected install just the bot + elif [[ JUST_BOT -eq 1 ]]; then + echo "Installing only the bot" + get_the_bot + # if the -l (bot and python) options is selected install just the bot and python dependencies + elif [[ BOT_AND_PYTHON -eq 1 ]]; then + echo "Installing only the bot and python dependencies" + get_the_bot + install_python_dep + # else, install everything + else + echo "Installing all" + install_deps + get_the_bot + install_python_dep + install_playwright + fi + + DIR="./RedditVideoMakerBot" + if [ -d "$DIR" ]; then + printf "\nThe bot is already installed, want to run it?" + # if -y (assume yes) continue + if [[ ASSUME_YES -eq 1 ]]; then + echo "Assuming yes" + # else, ask if they want to continue + else + echo "Continue? (y/n)" + read answer + # if the answer is not yes, then exit + if [ "$answer" != "y" ]; then + echo "Aborting" + exit 1 + fi + fi + cd RedditVideoMakerBot + python3 main.py + fi +} + +# Run the main function +install_main diff --git a/main.py b/main.py index c85a2aa..10ab3c1 100755 --- a/main.py +++ b/main.py @@ -1,20 +1,24 @@ #!/usr/bin/env python - +import math from subprocess import Popen -from os import getenv, name -from dotenv import load_dotenv +from os import name from reddit.subreddit import get_subreddit_threads from utils.cleanup import cleanup from utils.console import print_markdown, print_step -from utils.checker import check_env +from utils import settings -# from utils.checker import envUpdate -from video_creation.background import download_background, chop_background_video +from video_creation.background import ( + download_background, + chop_background_video, + get_background_config, +) from video_creation.final_video import make_final_video from video_creation.screenshot_downloader import download_screenshots_of_reddit_posts from video_creation.voices import save_text_to_mp3 -VERSION = "2.2.1" +__VERSION__ = "2.3" +__BRANCH__ = "master" + print( """ ██████╗ ███████╗██████╗ ██████╗ ██╗████████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ ███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗ @@ -29,40 +33,42 @@ print( print_markdown( "### Thanks for using this tool! [Feel free to contribute to this project on GitHub!](https://lewismenelaws.com) If you have any questions, feel free to reach out to me on Twitter or submit a GitHub issue. You can find solutions to many common problems in the [Documentation](https://luka-hietala.gitbook.io/documentation-for-the-reddit-bot/)" ) -print_step(f"You are using V{VERSION} of the bot") +print_step(f"You are using v{__VERSION__} of the bot") + def main(POST_ID=None): cleanup() reddit_object = get_subreddit_threads(POST_ID) length, number_of_comments = save_text_to_mp3(reddit_object) + length = math.ceil(length) download_screenshots_of_reddit_posts(reddit_object, number_of_comments) - download_background() - chop_background_video(length) - make_final_video(number_of_comments, length, reddit_object) + bg_config = get_background_config() + download_background(bg_config) + chop_background_video(bg_config, length) + make_final_video(number_of_comments, length, reddit_object, bg_config) def run_many(times): for x in range(1, times + 1): print_step( - f'on the {x}{("st" if x == 1 else ("nd" if x == 2 else ("rd" if x == 3 else "th")))} iteration of {times}' + f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th","th", "th")[x%10]} iteration of {times}' ) # correct 1st 2nd 3rd 4th 5th.... main() Popen("cls" if name == "nt" else "clear", shell=True).wait() if __name__ == "__main__": - if check_env() is not True: - exit() - load_dotenv() + config = settings.check_toml(".config.template.toml", "config.toml") + config is False and exit() try: - if getenv("TIMES_TO_RUN") and isinstance(int(getenv("TIMES_TO_RUN")), int): - run_many(int(getenv("TIMES_TO_RUN"))) + if config["settings"]["times_to_run"]: + run_many(config["settings"]["times_to_run"]) - elif len(getenv("POST_ID", "").split("+")) > 1: - for index, post_id in enumerate(getenv("POST_ID", "").split("+")): + elif len(config["reddit"]["thread"]["post_id"].split("+")) > 1: + for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")): index += 1 print_step( - f'on the {index}{("st" if index == 1 else ("nd" if index == 2 else ("rd" if index == 3 else "th")))} post of {len(getenv("POST_ID", "").split("+"))}' + 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("+"))}' ) main(post_id) Popen("cls" if name == "nt" else "clear", shell=True).wait() diff --git a/reddit/subreddit.py b/reddit/subreddit.py index e1f8940..716a7fa 100644 --- a/reddit/subreddit.py +++ b/reddit/subreddit.py @@ -1,12 +1,13 @@ import re -from os import getenv +from utils import settings import praw from praw.models import MoreComments from utils.console import print_step, print_substep from utils.subreddit import get_subreddit_undone from utils.videos import check_done +from utils.voice import sanitize_text def get_subreddit_threads(POST_ID: str): @@ -17,20 +18,20 @@ def get_subreddit_threads(POST_ID: str): print_substep("Logging into Reddit.") content = {} - if str(getenv("REDDIT_2FA")).casefold() == "yes": + if settings.config["reddit"]["creds"]["2fa"]: print("\nEnter your two-factor authentication code from your authenticator app.\n") code = input("> ") print() - pw = getenv("REDDIT_PASSWORD") + pw = settings.config["reddit"]["creds"]["password"] passkey = f"{pw}:{code}" else: - passkey = getenv("REDDIT_PASSWORD") - username = getenv("REDDIT_USERNAME") - if username.casefold().startswith("u/"): + passkey = settings.config["reddit"]["creds"]["password"] + username = settings.config["reddit"]["creds"]["username"] + if str(username).casefold().startswith("u/"): username = username[2:] reddit = praw.Reddit( - client_id=getenv("REDDIT_CLIENT_ID"), - client_secret=getenv("REDDIT_CLIENT_SECRET"), + client_id=settings.config["reddit"]["creds"]["client_id"], + client_secret=settings.config["reddit"]["creds"]["client_secret"], user_agent="Accessing Reddit threads", username=username, passkey=passkey, @@ -39,9 +40,9 @@ def get_subreddit_threads(POST_ID: str): # Ask user for subreddit input print_step("Getting subreddit threads...") - if not getenv( - "SUBREDDIT" - ): # note to user. you can have multiple subreddits via reddit.subreddit("redditdev+learnpython") + if not settings.config["reddit"]["thread"][ + "subreddit" + ]: # note to user. you can have multiple subreddits via reddit.subreddit("redditdev+learnpython") try: subreddit = reddit.subreddit( re.sub(r"r\/", "", input("What subreddit would you like to pull from? ")) @@ -51,9 +52,10 @@ def get_subreddit_threads(POST_ID: str): subreddit = reddit.subreddit("askreddit") print_substep("Subreddit not defined. Using AskReddit.") else: - print_substep(f"Using subreddit: r/{getenv('SUBREDDIT')} from environment variable config") - subreddit_choice = getenv("SUBREDDIT") - if subreddit_choice.casefold().startswith("r/"): # removes the r/ from the input + sub = settings.config["reddit"]["thread"]["subreddit"] + print_substep(f"Using subreddit: r/{sub} from TOML config") + subreddit_choice = sub + if str(subreddit_choice).casefold().startswith("r/"): # removes the r/ from the input subreddit_choice = subreddit_choice[2:] subreddit = reddit.subreddit( subreddit_choice @@ -61,14 +63,16 @@ def get_subreddit_threads(POST_ID: str): if POST_ID: # would only be called if there are multiple queued posts submission = reddit.submission(id=POST_ID) - elif getenv("POST_ID") and len(getenv("POST_ID").split("+")) == 1: - submission = reddit.submission(id=getenv("POST_ID")) + elif ( + settings.config["reddit"]["thread"]["post_id"] + and len(str(settings.config["reddit"]["thread"]["post_id"]).split("+")) == 1 + ): + submission = reddit.submission(id=settings.config["reddit"]["thread"]["post_id"]) else: - threads = subreddit.hot(limit=25) submission = get_subreddit_undone(threads, subreddit) submission = check_done(submission) # double-checking - if submission is None: + if submission is None or not submission.num_comments: return get_subreddit_threads(POST_ID) # submission already done. rerun upvotes = submission.score ratio = submission.upvote_ratio * 100 @@ -91,9 +95,15 @@ def get_subreddit_threads(POST_ID: str): if top_level_comment.body in ["[removed]", "[deleted]"]: continue # # see https://github.com/JasonLovesDoggo/RedditVideoMakerBot/issues/78 if not top_level_comment.stickied: - if len(top_level_comment.body) <= int(getenv("MAX_COMMENT_LENGTH", "500")): + sanitised = sanitize_text(top_level_comment.body) + if not sanitised or sanitised == " ": + continue + if len(top_level_comment.body) <= int( + settings.config["reddit"]["thread"]["max_comment_length"] + ): if ( top_level_comment.author is not None + and sanitize_text(top_level_comment.body) is not None ): # if errors occur with this change to if not. content["comments"].append( { diff --git a/requirements.txt b/requirements.txt index 2b7d9eb..e57a37d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,10 @@ boto3==1.24.24 botocore==1.27.24 gTTS==2.2.4 moviepy==1.0.3 -mutagen==1.45.1 playwright==1.23.0 praw==7.6.0 -python-dotenv==0.20.0 pytube==12.1.0 requests==2.28.1 rich==12.5.1 +toml==0.10.2 translators==5.3.1 diff --git a/utils/checker.py b/utils/checker.py deleted file mode 100755 index 791a376..0000000 --- a/utils/checker.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python -import os -from rich.console import Console -from rich.table import Table -from rich import box -import re -import dotenv -from utils.console import handle_input - -console = Console() - - -def check_env() -> bool: - """Checks to see what's been put in .env - - Returns: - bool: Whether or not everything was put in properly - """ - if not os.path.exists(".env.template"): - console.print("[red]Couldn't find .env.template. Unable to check variables.") - return True - if not os.path.exists(".env"): - console.print("[red]Couldn't find the .env file, creating one now.") - with open(".env", "x", encoding="utf-8") as file: - file.write("") - success = True - with open(".env.template", "r", encoding="utf-8") as template: - # req_envs = [env.split("=")[0] for env in template.readlines() if "=" in env] - matching = {} - explanations = {} - bounds = {} - types = {} - oob_errors = {} - examples = {} - req_envs = [] - var_optional = False - for line in template.readlines(): - if line.startswith("#") is not True and "=" in line and var_optional is not True: - req_envs.append(line.split("=")[0]) - if "#" in line: - examples[line.split("=")[0]] = "#".join(line.split("#")[1:]).strip() - elif "#OPTIONAL" in line: - var_optional = True - elif line.startswith("#MATCH_REGEX "): - matching[req_envs[-1]] = line.removeprefix("#MATCH_REGEX ")[:-1] - var_optional = False - elif line.startswith("#OOB_ERROR "): - oob_errors[req_envs[-1]] = line.removeprefix("#OOB_ERROR ")[:-1] - var_optional = False - elif line.startswith("#RANGE "): - bounds[req_envs[-1]] = tuple( - map( - lambda x: float(x) if x != "None" else None, - line.removeprefix("#RANGE ")[:-1].split(":"), - ) - ) - var_optional = False - elif line.startswith("#MATCH_TYPE "): - types[req_envs[-1]] = eval(line.removeprefix("#MATCH_TYPE ")[:-1].split()[0]) - var_optional = False - elif line.startswith("#EXPLANATION "): - explanations[req_envs[-1]] = line.removeprefix("#EXPLANATION ")[:-1] - var_optional = False - else: - var_optional = False - missing = set() - incorrect = set() - dotenv.load_dotenv() - for env in req_envs: - value = os.getenv(env) - if value is None: - missing.add(env) - continue - if env in matching.keys(): - re.match(matching[env], value) is None and incorrect.add(env) - if env in bounds.keys() and env not in types.keys(): - len(value) >= bounds[env][0] or ( - len(bounds[env]) > 1 and bounds[env][1] >= len(value) - ) or incorrect.add(env) - continue - if env in types.keys(): - try: - temp = types[env](value) - if env in bounds.keys(): - (bounds[env][0] <= temp or incorrect.add(env)) and len(bounds[env]) > 1 and ( - bounds[env][1] >= temp or incorrect.add(env) - ) - except ValueError: - incorrect.add(env) - - if len(missing): - table = Table( - title="Missing variables", - highlight=True, - show_lines=True, - box=box.ROUNDED, - border_style="#414868", - header_style="#C0CAF5 bold", - title_justify="left", - title_style="#C0CAF5 bold", - ) - table.add_column("Variable", justify="left", style="#7AA2F7 bold", no_wrap=True) - table.add_column("Explanation", justify="left", style="#BB9AF7", no_wrap=False) - table.add_column("Example", justify="center", style="#F7768E", no_wrap=True) - table.add_column("Min", justify="right", style="#F7768E", no_wrap=True) - table.add_column("Max", justify="left", style="#F7768E", no_wrap=True) - for env in missing: - table.add_row( - env, - explanations[env] if env in explanations.keys() else "No explanation given", - examples[env] if env in examples.keys() else "", - str(bounds[env][0]) if env in bounds.keys() and bounds[env][1] is not None else "", - str(bounds[env][1]) - if env in bounds.keys() and len(bounds[env]) > 1 and bounds[env][1] is not None - else "", - ) - console.print(table) - success = False - if len(incorrect): - table = Table( - title="Incorrect variables", - highlight=True, - show_lines=True, - box=box.ROUNDED, - border_style="#414868", - header_style="#C0CAF5 bold", - title_justify="left", - title_style="#C0CAF5 bold", - ) - table.add_column("Variable", justify="left", style="#7AA2F7 bold", no_wrap=True) - table.add_column("Current value", justify="left", style="#F7768E", no_wrap=False) - table.add_column("Explanation", justify="left", style="#BB9AF7", no_wrap=False) - table.add_column("Example", justify="center", style="#F7768E", no_wrap=True) - table.add_column("Min", justify="right", style="#F7768E", no_wrap=True) - table.add_column("Max", justify="left", style="#F7768E", no_wrap=True) - for env in incorrect: - table.add_row( - env, - os.getenv(env), - explanations[env] if env in explanations.keys() else "No explanation given", - str(types[env].__name__) if env in types.keys() else "str", - str(bounds[env][0]) if env in bounds.keys() else "None", - str(bounds[env][1]) if env in bounds.keys() and len(bounds[env]) > 1 else "None", - ) - missing.add(env) - console.print(table) - success = False - if success is True: - return True - console.print( - "[green]Do you want to automatically overwrite incorrect variables and add the missing variables? (y/n)" - ) - if not input().casefold().startswith("y"): - console.print("[red]Aborting: Unresolved missing variables") - return False - if len(incorrect): - with open(".env", "r+", encoding="utf-8") as env_file: - lines = [] - for line in env_file.readlines(): - line.split("=")[0].strip() not in incorrect and lines.append(line) - env_file.seek(0) - env_file.write("\n".join(lines)) - env_file.truncate() - console.print("[green]Successfully removed incorrectly set variables from .env") - with open(".env", "a", encoding="utf-8") as env_file: - for env in missing: - env_file.write( - env - + "=" - + ('"' if env not in types.keys() else "") - + str( - handle_input( - "[#F7768E bold]" + env + "[#C0CAF5 bold]=", - types[env] if env in types.keys() else False, - matching[env] if env in matching.keys() else ".*", - explanations[env] - if env in explanations.keys() - else "Incorrect input. Try again.", - bounds[env][0] if env in bounds.keys() else None, - bounds[env][1] if env in bounds.keys() and len(bounds[env]) > 1 else None, - oob_errors[env] if env in oob_errors.keys() else "Input too long/short.", - extra_info="[#C0CAF5 bold]⮶ " - + (explanations[env] if env in explanations.keys() else "No info available"), - ) - ) - + ('"' if env not in types.keys() else "") - + "\n" - ) - return True - - -if __name__ == "__main__": - check_env() diff --git a/utils/config.py b/utils/config.py deleted file mode 100644 index 29cbb79..0000000 --- a/utils/config.py +++ /dev/null @@ -1,46 +0,0 @@ -# write a class that takes .env file and parses it into a dictionary -from dotenv import dotenv_values - -DEFAULTS = { - "SUBREDDIT": "AskReddit", - "ALLOW_NSFW": "False", - "POST_ID": "", - "THEME": "DARK", - "REDDIT_2FA": "no", - "TIMES_TO_RUN": "", - "MAX_COMMENT_LENGTH": "500", - "OPACITY": "1", - "VOICE": "en_us_001", - "STORYMODE": "False", -} - - -class Config: - def __init__(self): - self.raw = dotenv_values("../.env") - self.load_attrs() - - def __getattr__(self, attr): # code completion for attributes fix. - return getattr(self, attr) - - def load_attrs(self): - for key, value in self.raw.items(): - self.add_attr(key, value) - - def add_attr(self, key, value): - if value is None or value == "": - setattr(self, key, DEFAULTS[key]) - else: - setattr(self, key, str(value)) - - -config = Config() - -print(config.SUBREDDIT) -# def temp(): -# root = '' -# if isinstance(root, praw.models.Submission): -# root_type = 'submission' -# elif isinstance(root, praw.models.Comment): -# root_type = 'comment' -# diff --git a/utils/console.py b/utils/console.py index 5b91fef..6f99a41 100644 --- a/utils/console.py +++ b/utils/console.py @@ -24,17 +24,17 @@ def print_step(text): console.print(panel) -def print_substep(text, style=""): - """Prints a rich info message without the panelling.""" - console.print(text, style=style) - - def print_table(items): """Prints items in a table.""" console.print(Columns([Panel(f"[yellow]{item}", expand=True) for item in items])) +def print_substep(text, style=""): + """Prints a rich info message without the panelling.""" + console.print(text, style=style) + + def handle_input( message: str = "", check_type=False, @@ -44,33 +44,77 @@ def handle_input( nmax=None, oob_error="", extra_info="", + options: list = None, + default=NotImplemented, + optional=False, ): - match = re.compile(match + "$") - console.print(extra_info, no_wrap=True) - while True: - console.print(message, end="") - user_input = input("").strip() - if re.match(match, user_input) is not None: + if optional: + console.print(message + "\n[green]This is an optional value. Do you want to skip it? (y/n)") + if input().casefold().startswith("y"): + return default if default is not NotImplemented else "" + if default is not NotImplemented: + console.print( + "[green]" + + message + + '\n[blue bold]The default value is "' + + str(default) + + '"\nDo you want to use it?(y/n)' + ) + if input().casefold().startswith("y"): + return default + if options is None: + match = re.compile(match) + console.print("[green bold]" + extra_info, no_wrap=True) + while True: + console.print(message, end="") + user_input = input("").strip() if check_type is not False: try: - user_input = check_type(user_input) # this line is fine - if nmin is not None and user_input < nmin: - console.print("[red]" + oob_error) # Input too low failstate - continue - if nmax is not None and user_input > nmax: - console.print("[red]" + oob_error) # Input too high + user_input = check_type(user_input) + if (nmin is not None and user_input < nmin) or ( + nmax is not None and user_input > nmax + ): + # FAILSTATE Input out of bounds + console.print("[red]" + oob_error) continue break # Successful type conversion and number in bounds except ValueError: - console.print("[red]" + err_message) # Type conversion failed + # Type conversion failed + console.print("[red]" + err_message) continue - if nmin is not None and len(user_input) < nmin: # Check if string is long enough - console.print("[red]" + oob_error) + elif match != "" and re.match(match, user_input) is None: + console.print("[red]" + err_message + "\nAre you absolutely sure it's correct?(y/n)") + if input().casefold().startswith("y"): + break continue - if nmax is not None and len(user_input) > nmax: # Check if string is not too long - console.print("[red]" + oob_error) + else: + # FAILSTATE Input STRING out of bounds + if (nmin is not None and len(user_input) < nmin) or ( + nmax is not None and len(user_input) > nmax + ): + console.print("[red bold]" + oob_error) + continue + break # SUCCESS Input STRING in bounds + return user_input + console.print(extra_info, no_wrap=True) + while True: + console.print(message, end="") + user_input = input("").strip() + if check_type is not False: + try: + isinstance(eval(user_input), check_type) + return check_type(user_input) + except: + console.print( + "[red bold]" + + err_message + + "\nValid options are: " + + ", ".join(map(str, options)) + + "." + ) continue - break - console.print("[red]" + err_message) - - return user_input + if user_input in options: + return user_input + console.print( + "[red bold]" + err_message + "\nValid options are: " + ", ".join(map(str, options)) + "." + ) diff --git a/utils/loader.py b/utils/loader.py deleted file mode 100644 index b9dc276..0000000 --- a/utils/loader.py +++ /dev/null @@ -1,51 +0,0 @@ -# Okay, have to admit. This code is from StackOverflow. It's so efficient, that it's probably the best way to do it. -# Although, it is edited to use less threads. - - -from itertools import cycle -from shutil import get_terminal_size -from threading import Thread -from time import sleep - - -class Loader: - def __init__(self, desc="Loading...", end="Done!", timeout=0.1): - """ - A loader-like context manager - - Args: - desc (str, optional): The loader's description. Defaults to "Loading...". - end (str, optional): Final print. Defaults to "Done!". - timeout (float, optional): Sleep time between prints. Defaults to 0.1. - """ - self.desc = desc - self.end = end - self.timeout = timeout - - self._thread = Thread(target=self._animate, daemon=True) - self.steps = ["⢿", "⣻", "⣽", "⣾", "⣷", "⣯", "⣟", "⡿"] - self.done = False - - def start(self): - self._thread.start() - return self - - def _animate(self): - for c in cycle(self.steps): - if self.done: - break - print(f"\r{self.desc} {c}", flush=True, end="") - sleep(self.timeout) - - def __enter__(self): - self.start() - - def stop(self): - self.done = True - cols = get_terminal_size((80, 20)).columns - print("\r" + " " * cols, end="", flush=True) - print(f"\r{self.end}", flush=True) - - def __exit__(self, exc_type, exc_value, tb): - # handle exceptions with those variables ^ - self.stop() diff --git a/utils/settings.py b/utils/settings.py new file mode 100755 index 0000000..a36f63e --- /dev/null +++ b/utils/settings.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python +import toml +from rich.console import Console +import re + +from typing import Tuple, Dict + +from utils.console import handle_input + + +console = Console() +config = dict # autocomplete + + +def crawl(obj: dict, func=lambda x, y: print(x, y, end="\n"), path=None): + if path is None: # path Default argument value is mutable + path = [] + for key in obj.keys(): + if type(obj[key]) is dict: + crawl(obj[key], func, path + [key]) + continue + func(path + [key], obj[key]) + + +def check(value, checks, name): + def get_check_value(key, default_result): + return checks[key] if key in checks else default_result + + incorrect = False + if value == {}: + incorrect = True + if not incorrect and "type" in checks: + try: + value = eval(checks["type"])(value) + except: + incorrect = True + + if ( + not incorrect and "options" in checks and value not in checks["options"] + ): # FAILSTATE Value is not one of the options + incorrect = True + if ( + not incorrect + and "regex" in checks + and ( + (isinstance(value, str) and re.match(checks["regex"], value) is None) + or not isinstance(value, str) + ) + ): # FAILSTATE Value doesn't match regex, or has regex but is not a string. + incorrect = True + + if ( + not incorrect + and not hasattr(value, "__iter__") + and ( + ("nmin" in checks and checks["nmin"] is not None and value < checks["nmin"]) + or ("nmax" in checks and checks["nmax"] is not None and value > checks["nmax"]) + ) + ): + incorrect = True + if ( + not incorrect + and hasattr(value, "__iter__") + and ( + ("nmin" in checks and checks["nmin"] is not None and len(value) < checks["nmin"]) + or ("nmax" in checks and checks["nmax"] is not None and len(value) > checks["nmax"]) + ) + ): + incorrect = True + + if incorrect: + value = handle_input( + message=( + (("[blue]Example: " + str(checks["example"]) + "\n") if "example" in checks else "") + + "[red]" + + ("Non-optional ", "Optional ")["optional" in checks and checks["optional"] is True] + ) + + "[#C0CAF5 bold]" + + str(name) + + "[#F7768E bold]=", + extra_info=get_check_value("explanation", ""), + check_type=eval(get_check_value("type", "False")), + default=get_check_value("default", NotImplemented), + match=get_check_value("regex", ""), + err_message=get_check_value("input_error", "Incorrect input"), + nmin=get_check_value("nmin", None), + nmax=get_check_value("nmax", None), + oob_error=get_check_value( + "oob_error", "Input out of bounds(Value too high/low/long/short)" + ), + options=get_check_value("options", None), + optional=get_check_value("optional", False), + ) + return value + + +def crawl_and_check(obj: dict, path: list, checks: dict = {}, name=""): + if len(path) == 0: + return check(obj, checks, name) + if path[0] not in obj.keys(): + obj[path[0]] = {} + obj[path[0]] = crawl_and_check(obj[path[0]], path[1:], checks, path[0]) + return obj + + +def check_vars(path, checks): + global config + crawl_and_check(config, path, checks) + + +def check_toml(template_file, config_file) -> Tuple[bool, Dict]: + global config + config = None + try: + template = toml.load(template_file) + except Exception as error: + console.print(f"[red bold]Encountered error when trying to to load {template_file}: {error}") + return False + try: + config = toml.load(config_file) + except toml.TomlDecodeError: + console.print( + f"""[blue]Couldn't read {config_file}. +Overwrite it?(y/n)""" + ) + if not input().startswith("y"): + print("Unable to read config, and not allowed to overwrite it. Giving up.") + return False + else: + try: + with open(config_file, "w") as f: + f.write("") + except: + console.print( + f"[red bold]Failed to overwrite {config_file}. Giving up.\nSuggestion: check {config_file} permissions for the user." + ) + return False + except FileNotFoundError: + console.print( + f"""[blue]Couldn't find {config_file} +Creating it now.""" + ) + try: + with open(config_file, "x") as f: + f.write("") + config = {} + except: + console.print( + f"[red bold]Failed to write to {config_file}. Giving up.\nSuggestion: check the folder's permissions for the user." + ) + return False + + console.print( + """\ +[blue bold]############################### +# # +# Checking TOML configuration # +# # +############################### +If you see any prompts, that means that you have unset/incorrectly set variables, please input the correct values.\ +""" + ) + crawl(template, check_vars) + with open(config_file, "w") as f: + toml.dump(config, f) + return config + + +if __name__ == "__main__": + check_toml(".config.template.toml", "config.toml") diff --git a/utils/subreddit.py b/utils/subreddit.py index f6ca686..4eb0108 100644 --- a/utils/subreddit.py +++ b/utils/subreddit.py @@ -1,5 +1,7 @@ import json -from os import getenv +from os.path import exists + +from utils import settings from utils.console import print_substep @@ -14,7 +16,9 @@ def get_subreddit_undone(submissions: list, subreddit): Any: The submission that has not been done """ # recursively checks if the top submission in the list was already done. - + if not exists("./video_creation/data/videos.json"): + with open("./video_creation/data/videos.json", "w+") as f: + json.dump([], f) with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw: done_videos = json.load(done_vids_raw) for submission in submissions: @@ -22,7 +26,7 @@ def get_subreddit_undone(submissions: list, subreddit): continue if submission.over_18: try: - if getenv("ALLOW_NSFW").casefold() == "false": + if not settings.config["settings"]["allow_nsfw"]: print_substep("NSFW Post Detected. Skipping...") continue except AttributeError: @@ -30,11 +34,16 @@ def get_subreddit_undone(submissions: list, subreddit): if submission.stickied: print_substep("This post was pinned by moderators. Skipping...") continue + if submission.num_comments <= int(settings.config["reddit"]["thread"]["min_comments"]): + print_substep( + f'This post has under the specified minimum of comments ({settings.config["reddit"]["thread"]["min_comments"]}). Skipping...' + ) + continue return submission print("all submissions have been done going by top submission order") return get_subreddit_undone( subreddit.top(time_filter="hour"), subreddit - ) # all of the videos in hot have already been done + ) # all the videos in hot have already been done def already_done(done_videos: list, submission) -> bool: diff --git a/utils/videos.py b/utils/videos.py index f675ecf..4a91e8c 100755 --- a/utils/videos.py +++ b/utils/videos.py @@ -1,33 +1,32 @@ import json -import os import time -from os import getenv +from typing import Dict from praw.models import Submission +from utils import settings from utils.console import print_step def check_done( - redditobj: dict[str], + redditobj: Submission, ) -> Submission: # don't set this to be run anyplace that isn't subreddit.py bc of inspect stack """Checks if the chosen post has already been generated Args: - redditobj (dict[str]): Reddit object gotten from reddit/subreddit.py + redditobj (Submission): Reddit object gotten from reddit/subreddit.py Returns: - dict[str]|None: Reddit object in args + Submission|None: Reddit object in args """ - with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw: done_videos = json.load(done_vids_raw) for video in done_videos: if video["id"] == str(redditobj): - if getenv("POST_ID"): + if settings.config["reddit"]["thread"]["post_id"]: print_step( - "You already have done this video but since it was declared specifically in the .env file the program will continue" + "You already have done this video but since it was declared specifically in the config file the program will continue" ) return redditobj print_step("Getting new post as the current one has already been done") @@ -35,11 +34,12 @@ def check_done( return redditobj -def save_data(filename: str, reddit_title: str, reddit_id: str): +def save_data(subreddit: str, filename: str, reddit_title: str, reddit_id: str, credit: str): """Saves the videos that have already been generated to a JSON file in video_creation/data/videos.json Args: filename (str): The finished video title name + @param subreddit: @param filename: @param reddit_id: @param reddit_title: @@ -47,11 +47,12 @@ def save_data(filename: str, reddit_title: str, reddit_id: str): with open("./video_creation/data/videos.json", "r+", encoding="utf-8") as raw_vids: done_vids = json.load(raw_vids) if reddit_id in [video["id"] for video in done_vids]: - return # video already done but was specified to continue anyway in the .env file + return # video already done but was specified to continue anyway in the config file payload = { + "subreddit": subreddit, "id": reddit_id, "time": str(int(time.time())), - "background_credit": str(os.getenv("background_credit")), + "background_credit": credit, "reddit_title": reddit_title, "filename": filename, } diff --git a/utils/voice.py b/utils/voice.py index c4f27bf..a0709fa 100644 --- a/utils/voice.py +++ b/utils/voice.py @@ -1,4 +1,65 @@ import re +import sys +from datetime import datetime +import time as pytime +from time import sleep + +from requests import Response + +if sys.version_info[0] >= 3: + from datetime import timezone + + +def check_ratelimit(response: Response): + """ + Checks if the response is a ratelimit response. + If it is, it sleeps for the time specified in the response. + """ + if response.status_code == 429: + try: + time = int(response.headers["X-RateLimit-Reset"]) + print(f"Ratelimit hit. Sleeping for {time - int(pytime.time())} seconds.") + sleep_until(time) + return False + except KeyError: # if the header is not present, we don't know how long to wait + return False + + return True + + +def sleep_until(time): + """ + Pause your program until a specific end time. + 'time' is either a valid datetime object or unix timestamp in seconds (i.e. seconds since Unix epoch) + """ + end = time + + # Convert datetime to unix timestamp and adjust for locality + if isinstance(time, datetime): + # If we're on Python 3 and the user specified a timezone, convert to UTC and get the timestamp. + if sys.version_info[0] >= 3 and time.tzinfo: + end = time.astimezone(timezone.utc).timestamp() + else: + zoneDiff = pytime.time() - (datetime.now() - datetime(1970, 1, 1)).total_seconds() + end = (time - datetime(1970, 1, 1)).total_seconds() + zoneDiff + + # Type check + if not isinstance(end, (int, float)): + raise Exception("The time parameter is not a number or datetime object") + + # Now we wait + while True: + now = pytime.time() + diff = end - now + + # + # Time is up! + # + if diff <= 0: + break + else: + # 'logarithmic' sleeping to minimize loop iterations + sleep(diff / 2) def sanitize_text(text: str) -> str: @@ -20,7 +81,7 @@ def sanitize_text(text: str) -> str: result = re.sub(regex_urls, " ", text) # note: not removing apostrophes - regex_expr = r"\s['|’]|['|’]\s|[\^_~@!&;#:\-%“”‘\"%\*/{}\[\]\(\)\\|<>=+]" + regex_expr = r"\s['|’]|['|’]\s|[\^_~@!&;#:\-–—%“”‘\"%\*/{}\[\]\(\)\\|<>=+]" result = re.sub(regex_expr, " ", result) result = result.replace("+", "plus").replace("&", "and") # remove extra whitespace diff --git a/video_creation/background.py b/video_creation/background.py index 7bf6ae2..be0f46c 100644 --- a/video_creation/background.py +++ b/video_creation/background.py @@ -1,16 +1,53 @@ -import random -from os import listdir, environ from pathlib import Path +import random from random import randrange +from typing import Any, Tuple + from moviepy.editor import VideoFileClip from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip from pytube import YouTube +from pytube.cli import on_progress +from utils import settings from utils.console import print_step, print_substep +# Supported Background. Can add/remove background video here.... +# - : key -> used as keyword for TOML file. value -> background configuration +# Format (value): +# 1. Youtube URI +# 2. filename +# 3. Citation (owner of the video) +# 4. Position of image clips in the background. See moviepy reference for more information. (https://zulko.github.io/moviepy/ref/VideoClip/VideoClip.html#moviepy.video.VideoClip.VideoClip.set_position) +background_options = { + "motor-gta": ( # Motor-GTA Racing + "https://www.youtube.com/watch?v=vw5L4xCPy9Q", + "bike-parkour-gta.mp4", + "Achy Gaming", + lambda t: ("center", 480 + t), + ), + "rocket-league": ( # Rocket League + "https://www.youtube.com/watch?v=2X9QGY__0II", + "rocket_league.mp4", + "Orbital Gameplay", + lambda t: ("center", 200 + t), + ), + "minecraft": ( # Minecraft parkour + "https://www.youtube.com/watch?v=n_Dv4JMiwK8", + "parkour.mp4", + "bbswitzer", + "center", + ), + "gta": ( # GTA Stunt Race + "https://www.youtube.com/watch?v=qGa9kWREOnE", + "gta-stunt-race.mp4", + "Achy Gaming", + lambda t: ("center", 480 + t), + ), +} -def get_start_and_end_times(video_length: int, length_of_clip: int) -> tuple[int, int]: + +def get_start_and_end_times(video_length: int, length_of_clip: int) -> Tuple[int, int]: """Generates a random interval of time to be used as the background of the video. Args: @@ -24,45 +61,50 @@ def get_start_and_end_times(video_length: int, length_of_clip: int) -> tuple[int return random_time, random_time + video_length -def download_background(): - """Downloads the backgrounds/s video from YouTube.""" +def get_background_config(): + """Fetch the background/s configuration""" + try: + choice = str(settings.config["settings"]["background"]["background_choice"]).casefold() + except AttributeError: + print_substep("No background selected. Picking random background'") + choice = None + + # Handle default / not supported background using default option. + # Default : pick random from supported background. + if not choice or choice not in background_options: + choice = random.choice(list(background_options.keys())) + + return background_options[choice] + + +def download_background(background_config: Tuple[str, str, str, Any]): + """Downloads the background/s video from YouTube.""" Path("./assets/backgrounds/").mkdir(parents=True, exist_ok=True) - background_options = [ # uri , filename , credit - ("https://www.youtube.com/watch?v=n_Dv4JMiwK8", "parkour.mp4", "bbswitzer"), - # ( - # "https://www.youtube.com/watch?v=2X9QGY__0II", - # "rocket_league.mp4", - # "Orbital Gameplay", - # ), - ] # note: make sure the file name doesn't include an - in it - if not len(listdir("./assets/backgrounds")) >= len( - background_options - ): # if there are any background videos not installed - print_step( - "We need to download the backgrounds videos. they are fairly large but it's only done once. 😎" - ) - print_substep("Downloading the backgrounds videos... please be patient 🙏 ") - for uri, filename, credit in background_options: - if Path(f"assets/backgrounds/{credit}-{filename}").is_file(): - continue # adds check to see if file exists before downloading - print_substep(f"Downloading {filename} from {uri}") - YouTube(uri).streams.filter(res="1080p").first().download( - "assets/backgrounds", filename=f"{credit}-{filename}" - ) - - print_substep("Background videos downloaded successfully! 🎉", style="bold green") + uri, filename, credit, _ = background_config + if Path(f"assets/backgrounds/{credit}-{filename}").is_file(): + return + print_step( + "We need to download the backgrounds videos. they are fairly large but it's only done once. 😎" + ) + print_substep("Downloading the backgrounds videos... please be patient 🙏 ") + print_substep(f"Downloading {filename} from {uri}") + YouTube(uri, on_progress_callback=on_progress).streams.filter(res="1080p").first().download( + "assets/backgrounds", filename=f"{credit}-{filename}" + ) + print_substep("Background videos downloaded successfully! 🎉", style="bold green") -def chop_background_video(video_length: int): +def chop_background_video(background_config: Tuple[str, str, str, Any], video_length: int): """Generates the background footage to be used in the video and writes it to assets/temp/background.mp4 Args: + background_config (Tuple[str, str, str, Any]) : Current background configuration video_length (int): Length of the clip where the background footage is to be taken out of """ + print_step("Finding a spot in the backgrounds video to chop...✂️") - choice = random.choice(listdir("assets/backgrounds")) - environ["background_credit"] = choice.split("-")[0] + choice = f"{background_config[2]}-{background_config[1]}" background = VideoFileClip(f"assets/backgrounds/{choice}") @@ -80,3 +122,4 @@ def chop_background_video(video_length: int): new = video.subclip(start_time, end_time) new.write_videofile("assets/temp/background.mp4") print_substep("Background video chopped successfully!", style="bold green") + return background_config[2] diff --git a/video_creation/final_video.py b/video_creation/final_video.py index d170169..8524051 100755 --- a/video_creation/final_video.py +++ b/video_creation/final_video.py @@ -3,39 +3,67 @@ import multiprocessing import os import re from os.path import exists - -from moviepy.editor import ( - VideoFileClip, - AudioFileClip, - ImageClip, - concatenate_videoclips, - concatenate_audioclips, - CompositeAudioClip, - CompositeVideoClip, -) -from moviepy.video.io import ffmpeg_tools +from typing import Tuple, Any +from moviepy.audio.AudioClip import concatenate_audioclips, CompositeAudioClip +from moviepy.audio.io.AudioFileClip import AudioFileClip +from moviepy.video.VideoClip import ImageClip +from moviepy.video.compositing.CompositeVideoClip import CompositeVideoClip +from moviepy.video.compositing.concatenate import concatenate_videoclips +from moviepy.video.io.VideoFileClip import VideoFileClip +from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip from rich.console import Console from utils.cleanup import cleanup from utils.console import print_step, print_substep from utils.videos import save_data +from utils import settings console = Console() - W, H = 1080, 1920 -def make_final_video(number_of_clips: int, length: int, reddit_obj: dict[str]): - """Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp +def name_normalize(name: str) -> str: + name = re.sub(r'[?\\"%*:|<>]', "", name) + name = re.sub(r"( [w,W]\s?\/\s?[o,O,0])", r" without", name) + name = re.sub(r"( [w,W]\s?\/)", r" with", name) + name = re.sub(r"(\d+)\s?\/\s?(\d+)", r"\1 of \2", name) + name = re.sub(r"(\w+)\s?\/\s?(\w+)", r"\1 or \2", name) + name = re.sub(r"\/", r"", name) + lang = settings.config["reddit"]["thread"]["post_lang"] + if lang: + import translators as ts + + print_substep("Translating filename...") + translated_name = ts.google(name, to_language=lang) + return translated_name + + else: + return name + + +def make_final_video( + number_of_clips: int, + length: int, + reddit_obj: dict, + background_config: Tuple[str, str, str, Any], +): + """Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp Args: - number_of_clips (int): Index to end at when going through the screenshots + number_of_clips (int): Index to end at when going through the screenshots' length (int): Length of the video + reddit_obj (dict): The reddit object that contains the posts to read. + background_config (Tuple[str, str, str, Any]): The background config to use. """ + # try: # if it isn't found (i.e you just updated and copied over config.toml) it will throw an error + # VOLUME_MULTIPLIER = settings.config["settings"]['background']["background_audio_volume"] + # except (TypeError, KeyError): + # print('No background audio volume found in config.toml. Using default value of 1.') + # VOLUME_MULTIPLIER = 1 print_step("Creating the final video 🎥") VideoFileClip.reW = lambda clip: clip.resize(width=W) VideoFileClip.reH = lambda clip: clip.resize(width=H) - opacity = os.getenv("OPACITY") + opacity = settings.config["settings"]["opacity"] background_clip = ( VideoFileClip("assets/temp/background.mp4") .without_audio() @@ -44,57 +72,31 @@ def make_final_video(number_of_clips: int, length: int, reddit_obj: dict[str]): ) # Gather all audio clips - audio_clips = [] - for i in range(0, number_of_clips): - audio_clips.append(AudioFileClip(f"assets/temp/mp3/{i}.mp3")) + audio_clips = [AudioFileClip(f"assets/temp/mp3/{i}.mp3") for i in range(number_of_clips)] audio_clips.insert(0, AudioFileClip("assets/temp/mp3/title.mp3")) audio_concat = concatenate_audioclips(audio_clips) audio_composite = CompositeAudioClip([audio_concat]) - # Get sum of all clip lengths - total_length = sum([clip.duration for clip in audio_clips]) - # round total_length to an integer - int_total_length = round(total_length) - # Output Length - - console.log(f"[bold green] Video Will Be: {int_total_length} Seconds Long") + console.log(f"[bold green] Video Will Be: {length} Seconds Long") # add title to video image_clips = [] # Gather all images - if opacity is None or float(opacity) >= 1: # opacity not set or is set to one OR MORE - image_clips.insert( - 0, - ImageClip("assets/temp/png/title.png") - .set_duration(audio_clips[0].duration) - .set_position("center") - .resize(width=W - 100), - ) - else: - image_clips.insert( - 0, - ImageClip("assets/temp/png/title.png") - .set_duration(audio_clips[0].duration) - .set_position("center") - .resize(width=W - 100) - .set_opacity(float(opacity)), - ) + new_opacity = 1 if opacity is None or float(opacity) >= 1 else float(opacity) + image_clips.insert( + 0, + ImageClip("assets/temp/png/title.png") + .set_duration(audio_clips[0].duration) + .resize(width=W - 100) + .set_opacity(new_opacity), + ) for i in range(0, number_of_clips): - if opacity is None or float(opacity) >= 1: # opacity not set or is set to one OR MORE - image_clips.append( - ImageClip(f"assets/temp/png/comment_{i}.png") - .set_duration(audio_clips[i + 1].duration) - .set_position("center") - .resize(width=W - 100), - ) - else: - image_clips.append( - ImageClip(f"assets/temp/png/comment_{i}.png") - .set_duration(audio_clips[i + 1].duration) - .set_position("center") - .resize(width=W - 100) - .set_opacity(float(opacity)), - ) + image_clips.append( + ImageClip(f"assets/temp/png/comment_{i}.png") + .set_duration(audio_clips[i + 1].duration) + .resize(width=W - 100) + .set_opacity(new_opacity) + ) # if os.path.exists("assets/mp3/posttext.mp3"): # image_clips.insert( @@ -105,21 +107,29 @@ def make_final_video(number_of_clips: int, length: int, reddit_obj: dict[str]): # .resize(width=W - 100) # .set_opacity(float(opacity)), # ) - # else: - image_concat = concatenate_videoclips(image_clips).set_position(("center", "center")) + # else: story mode stuff + img_clip_pos = background_config[3] + image_concat = concatenate_videoclips(image_clips).set_position(img_clip_pos) image_concat.audio = audio_composite final = CompositeVideoClip([background_clip, image_concat]) title = re.sub(r"[^\w\s-]", "", reddit_obj["thread_title"]) idx = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"]) - filename = f"{title}.mp4" - subreddit = os.getenv("SUBREDDIT") - save_data(filename, title, idx) + filename = f"{name_normalize(title)}.mp4" + subreddit = settings.config["reddit"]["thread"]["subreddit"] if not exists(f"./results/{subreddit}"): print_substep("The results folder didn't exist so I made it") os.makedirs(f"./results/{subreddit}") + # if settings.config["settings"]['background']["background_audio"] and exists(f"assets/backgrounds/background.mp3"): + # audioclip = mpe.AudioFileClip(f"assets/backgrounds/background.mp3").set_duration(final.duration) + # audioclip = audioclip.fx( volumex, 0.2) + # final_audio = mpe.CompositeAudioClip([final.audio, audioclip]) + # # lowered_audio = audio_background.multiply_volume( # todo get this to work + # # VOLUME_MULTIPLIER) # lower volume by background_audio_volume, use with fx + # final.set_audio(final_audio) + final.write_videofile( "assets/temp/temp.mp4", fps=30, @@ -128,16 +138,18 @@ def make_final_video(number_of_clips: int, length: int, reddit_obj: dict[str]): verbose=False, threads=multiprocessing.cpu_count(), ) - ffmpeg_tools.ffmpeg_extract_subclip( - "assets/temp/temp.mp4", 0, length, targetname=f"results/{subreddit}/{filename}" + ffmpeg_extract_subclip( + "assets/temp/temp.mp4", + 0, + final.duration, + targetname=f"results/{subreddit}/{filename}", ) - # os.remove("assets/temp/temp.mp4") - + save_data(subreddit, filename, title, idx, background_config[2]) print_step("Removing temporary files 🗑") cleanups = cleanup() print_substep(f"Removed {cleanups} temporary files 🗑") print_substep("See result in the results folder!") print_step( - f'Reddit title: {reddit_obj["thread_title"]} \n Background Credit: {os.getenv("background_credit")}' + f'Reddit title: {reddit_obj["thread_title"]} \n Background Credit: {background_config[2]}' ) diff --git a/video_creation/screenshot_downloader.py b/video_creation/screenshot_downloader.py index aa1c9d9..6fb9ef4 100644 --- a/video_creation/screenshot_downloader.py +++ b/video_creation/screenshot_downloader.py @@ -1,8 +1,8 @@ import json -import os -from os import getenv -from pathlib import Path +from pathlib import Path +from typing import Dict +from utils import settings from playwright.async_api import async_playwright # pylint: disable=unused-import # do not remove the above line @@ -16,14 +16,13 @@ from utils.console import print_step, print_substep storymode = False -def download_screenshots_of_reddit_posts(reddit_object: dict[str], screenshot_num: int): +def download_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): """Downloads screenshots of reddit posts as seen on the web. Downloads to assets/temp/png Args: - reddit_object (dict[str]): Reddit object received from reddit/subreddit.py - screenshot_num (int): Number of screenshots to downlaod + reddit_object (Dict): Reddit object received from reddit/subreddit.py + screenshot_num (int): Number of screenshots to download """ - print_step("Downloading screenshots of reddit posts...") # ! Make sure the reddit screenshots folder exists @@ -35,7 +34,7 @@ def download_screenshots_of_reddit_posts(reddit_object: dict[str], screenshot_nu browser = p.chromium.launch() context = browser.new_context() - if getenv("THEME").upper() == "DARK": + if settings.config["settings"]["theme"] == "dark": cookie_file = open("./video_creation/data/cookie-dark-mode.json", encoding="utf-8") else: cookie_file = open("./video_creation/data/cookie-light-mode.json", encoding="utf-8") @@ -56,9 +55,12 @@ def download_screenshots_of_reddit_posts(reddit_object: dict[str], screenshot_nu # translate code - if getenv("POSTLANG"): + if settings.config["reddit"]["thread"]["post_lang"]: print_substep("Translating post...") - texts_in_tl = ts.google(reddit_object["thread_title"], to_language=os.getenv("POSTLANG")) + texts_in_tl = ts.google( + reddit_object["thread_title"], + to_language=settings.config["reddit"]["thread"]["post_lang"], + ) page.evaluate( "tl_content => document.querySelector('[data-test-id=\"post-content\"] > div:nth-child(3) > div > div').textContent = tl_content", @@ -88,9 +90,10 @@ def download_screenshots_of_reddit_posts(reddit_object: dict[str], screenshot_nu # translate code - if getenv("POSTLANG"): + if settings.config["reddit"]["thread"]["post_lang"]: comment_tl = ts.google( - comment["comment_body"], to_language=os.getenv("POSTLANG") + comment["comment_body"], + to_language=settings.config["reddit"]["thread"]["post_lang"], ) page.evaluate( '([tl_content, tl_id]) => document.querySelector(`#t1_${tl_id} > div:nth-child(2) > div > div[data-testid="comment"] > div`).textContent = tl_content', diff --git a/video_creation/voices.py b/video_creation/voices.py index 240c851..ac33dd7 100644 --- a/video_creation/voices.py +++ b/video_creation/voices.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -import os +from typing import Dict, Tuple from rich.console import Console @@ -9,7 +9,7 @@ from TTS.GTTS import GTTS from TTS.streamlabs_polly import StreamlabsPolly from TTS.aws_polly import AWSPolly from TTS.TikTok import TikTok - +from utils import settings from utils.console import print_table, print_step @@ -23,19 +23,19 @@ TTSProviders = { } -def save_text_to_mp3(reddit_obj: dict[str]) -> tuple[int, int]: +def save_text_to_mp3(reddit_obj) -> Tuple[int, int]: """Saves text to MP3 files. Args: - reddit_obj (dict[str]): Reddit object received from reddit API in reddit/subreddit.py + reddit_obj (): Reddit object received from reddit API in reddit/subreddit.py Returns: tuple[int,int]: (total length of the audio, the number of comments audio was generated for) """ - env = os.getenv("TTSCHOICE", "") - if env.casefold() in map(lambda _: _.casefold(), TTSProviders): - text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, env), reddit_obj) + voice = settings.config["settings"]["tts"]["choice"] + if str(voice).casefold() in map(lambda _: _.casefold(), TTSProviders): + text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, voice), reddit_obj) else: while True: print_step("Please choose one of the following TTS providers: ") @@ -45,7 +45,6 @@ def save_text_to_mp3(reddit_obj: dict[str]) -> tuple[int, int]: break print("Unknown Choice") text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, choice), reddit_obj) - return text_to_mp3.run()