diff --git a/.gitignore b/.gitignore index 4ee3693..74ef56c 100644 --- a/.gitignore +++ b/.gitignore @@ -233,6 +233,7 @@ fabric.properties assets/ out +venv .DS_Store .setup-done-before results/* diff --git a/README.md b/README.md index 77b11b2..8aa310d 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,16 @@ The only original thing being done is the editing and gathering of all materials 2b **Manual Install**: Rename `.env.template` to `.env` and replace all values with the appropriate fields. To get Reddit keys (**required**), visit [the Reddit Apps page.](https://www.reddit.com/prefs/apps) TL;DR set up an app that is a "script". Copy your keys into the `.env` file, along with whether your account uses two-factor authentication. 3. Run `pip install -r requirements.txt` +4. Enjoy 😎 -4. Run `playwright install` and `playwright install-deps`. (if this fails try adding python -m to the front of the command) +5. Run `playwright install` and `playwright install-deps`. (if this fails try adding python -m to the front of the command) -5. Run `python main.py` (unless you chose automatic install, then the installer will automatically run main.py) +6. Run `python main.py` (unless you chose automatic install, then the installer will automatically run main.py) required\*\*), visit [the Reddit Apps page.](https://www.reddit.com/prefs/apps) TL;DR set up an app that is a "script". Copy your keys into the `.env` file, along with whether your account uses two-factor authentication. -6. Enjoy 😎 +7. Enjoy 😎 + +There's also now also a CLI! Run `python3 cli.py [OPTIONS (arguments)]` to do the task, do `python3 cli.py -h` for help. (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) diff --git a/build.sh b/build.sh index 45ebd33..165357f 100755 --- a/build.sh +++ b/build.sh @@ -1,2 +1 @@ -#!/bin/sh docker build -t rvmt . diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..893e3c2 --- /dev/null +++ b/cli.py @@ -0,0 +1,83 @@ +import argparse + +from main import main +from setup_program import setup +from utils.console import print_substep + + +def program_options(): + + description = """\ + Create Reddit Videos with one command. + """ + + parser = argparse.ArgumentParser( + prog="RedditVideoMakerBot", # can be renamed, just a base + usage="RedditVideoMakerBot [OPTIONS]", + description=description + ) + parser.add_argument( + "-c", "--create", + help="Create a video (uses the defaults).", + action="store_true" + ) + parser.add_argument( # only accepts the name of subreddit, not links. + "-s", "--subreddit", + help="Specify a subreddit.", + action="store" + ) + parser.add_argument( + "-b", "--background", + help="Specify a video background for video (accepts link and file).", + action="store" + ) + parser.add_argument( + "-f", "--filename", + help="Specify a filename for the video.", + action="store" + ) + parser.add_argument( + "-t", "--thread", + help="Use the given thread link instead of random.", + action="store" + ) + parser.add_argument( + "-n", "--number", + help="Specify number of comments to include in the video.", + action="store" + ) + parser.add_argument( + "--setup", "--setup", + help="(Re)setup the program.", + action="store_true" + ) + + args = parser.parse_args() + + try: + if args.create: + while True: + create = main( + args.subreddit, + args.background, + args.filename, + args.thread, + args.number, + ) + if not create: + try_again = input("Something went wrong! Try again? [y/N] > ").strip() + if try_again in ["y", "Y"]: + continue + + break + elif args.setup: + setup() + else: + print_substep("Error occured!", style_="bold red") + raise SystemExit() + except KeyboardInterrupt: + print_substep("\nOperation Aborted!", style_="bold red") + + +if __name__ == "__main__": + program_options() diff --git a/main.py b/main.py index 5f01e5f..863a4bc 100755 --- a/main.py +++ b/main.py @@ -31,7 +31,13 @@ print_markdown( ) -def main(): +def main( + subreddit_=None, + background=None, + filename=None, + thread_link_=None, + number_of_comments=None + ): if check_env() is not True: exit() load_dotenv() @@ -39,12 +45,16 @@ def main(): - reddit_object = get_subreddit_threads() + reddit_object = get_subreddit_threads( + subreddit_, + thread_link_, + number_of_comments + ) length, number_of_comments = save_text_to_mp3(reddit_object) download_screenshots_of_reddit_posts(reddit_object, number_of_comments) - download_background() + download_background(background) chop_background_video(length) - make_final_video(number_of_comments, length) + make_final_video(number_of_comments, length,filename) def run_many(times): diff --git a/reddit/subreddit.py b/reddit/subreddit.py index 124380b..f80889c 100644 --- a/reddit/subreddit.py +++ b/reddit/subreddit.py @@ -1,3 +1,5 @@ +import random +import os import re from os import getenv, environ @@ -22,77 +24,144 @@ def try_env(param, backup): return backup -def get_subreddit_threads(): +from prawcore.exceptions import ( + OAuthException, + ResponseException, + RequestException, + BadRequest +) +from dotenv import load_dotenv + +from utils.console import print_step, print_substep + + +def get_subreddit_threads(subreddit_, thread_link_, number_of_comments): """ - Returns a list of threads from the AskReddit subreddit. + Takes subreddit_ as parameter which defaults to None, but in this + case since it is None, it would raise ValueError, thus defaulting + to AskReddit. + + Returns a list of threads from the provided subreddit. """ + global submission - print_substep("Logging into Reddit.") + load_dotenv() - content = {} - if str(getenv("REDDIT_2FA")).casefold() == "yes": + if os.getenv("REDDIT_2FA", default="no").casefold() == "yes": print( - "\nEnter your two-factor authentication code from your authenticator app.\n" + "\nEnter your two-factor authentication code from your authenticator app.\n", end=" " ) code = input("> ") - print() - pw = getenv("REDDIT_PASSWORD") + pw = os.getenv("REDDIT_PASSWORD") passkey = f"{pw}:{code}" else: - passkey = getenv("REDDIT_PASSWORD") - reddit = praw.Reddit( - client_id=getenv("REDDIT_CLIENT_ID"), - client_secret=getenv("REDDIT_CLIENT_SECRET"), - user_agent="Accessing Reddit threads", - username=getenv("REDDIT_USERNAME"), - passkey=passkey, - check_for_async=False, - ) - """ - 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") + passkey = os.getenv("REDDIT_PASSWORD") + + content = {} + try: + reddit = praw.Reddit( + client_id=os.getenv("REDDIT_CLIENT_ID").strip(), + client_secret=os.getenv("REDDIT_CLIENT_SECRET").strip(), + user_agent="Accessing AskReddit threads", + username=os.getenv("REDDIT_USERNAME").strip(), + password=passkey.strip(), + ) + except ( + OAuthException, + ResponseException, + RequestException, + BadRequest + ): + print_substep( + "[bold red]There is something wrong with the .env file, kindly check:[/bold red]\n" + + "1. ClientID\n" + + "2. ClientSecret\n" + + "3. If these variables are fine, kindly check other variables.\n" + + "4. Check if the type of Reddit app created is script (personal use script)." + ) + + + # If the user specifies that he doesnt want a random thread, or if + # he doesn't insert the "RANDOM_THREAD" variable at all, ask the thread link + while True: + if thread_link_ is not None: + thread_link = thread_link_ + print_step("Getting the inserted thread...") + submission = reddit.submission(url=thread_link) + else: + try: + if subreddit_ is None: + raise ValueError + + subreddit = reddit.subreddit( + re.sub(r"r\/", "", subreddit_.strip()) + ) + except ValueError: + if os.getenv("SUBREDDIT"): + subreddit = reddit.subreddit( + re.sub(r"r\/", "", os.getenv("SUBREDDIT").strip()) + ) + else: + subreddit = reddit.subreddit("askreddit") + print_substep("Subreddit not defined. Using AskReddit.") + + threads = subreddit.hot(limit=25) + submission = list(threads)[random.randrange(0, 25)] + try: - subreddit = reddit.subreddit( - re.sub( - r"r\/", "", input("What subreddit would you like to pull from? ") + with open("created_videos", "r", encoding="utf-8") as reference: + videos = list(reference.readlines()) + + if submission.title in videos: + print_substep( + "[bold]There is already a video for thread: [cyan]" + + f"{submission.title}[/cyan]. Finding another one.[/bold]" ) - # removes the r/ from the input + continue + except FileNotFoundError: + break + + if len(submission.comments) == 0: + print_substep( + "The thread do not contain any comments. Searching for new one.", style_="bold" ) - except ValueError: - 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 = reddit.subreddit( - getenv("SUBREDDIT") - ) # Allows you to specify in .env. Done for automation purposes. - if getenv("POST_ID"): - submission = reddit.submission(id=getenv("POST_ID")) - else: - threads = subreddit.hot(limit=25) - submission = get_subreddit_undone(threads, subreddit) - submission = check_done(submission) # double checking - if submission is None: - return get_subreddit_threads() # submission already done. rerun upvotes = submission.score ratio = submission.upvote_ratio * 100 num_comments = submission.num_comments - print_substep(f"Video will be: {submission.title} :thumbsup:", style="bold green") - print_substep(f"Thread has {upvotes} upvotes", style="bold blue") - print_substep(f"Thread has a upvote ratio of {ratio}%", style="bold blue") - print_substep(f"Thread has {num_comments} comments", style="bold blue") - environ["VIDEO_TITLE"] = str( - textify(submission.title) - ) # todo use global instend of env vars - environ["VIDEO_ID"] = str(textify(submission.id)) + print_substep( + f"[bold]Video will be: [cyan]{submission.title}[/cyan] :thumbsup:\n" + + f"[blue] Thread has {upvotes} and upvote ratio of {ratio}%\n" + + f"And has a {num_comments}[/blue].\n" + ) + + + try: + content["thread_url"] = submission.url + content["thread_title"] = submission.title + content["thread_post"] = submission.selftext + content["comments"] = [] + + comment_count = 0 + for top_level_comment in submission.comments: + if number_of_comments is not None: + if comment_count == number_of_comments: + break + + if not top_level_comment.stickied: + content["comments"].append( + { + "comment_body": top_level_comment.body, + "comment_url": top_level_comment.permalink, + "comment_id": top_level_comment.id, + } + ) + count += 1 + except AttributeError: + pass + + print_substep("AskReddit threads retrieved successfully.", style_="bold green") content["thread_url"] = f"https://reddit.com{submission.permalink}" content["thread_title"] = submission.title diff --git a/run.sh b/run.sh index 1769e21..1f86c99 100755 --- a/run.sh +++ b/run.sh @@ -1,2 +1 @@ -#!/bin/sh docker run -v $(pwd)/out/:/app/assets -v $(pwd)/.env:/app/.env -it rvmt diff --git a/setup_program.py b/setup_program.py new file mode 100644 index 0000000..1b7d6dd --- /dev/null +++ b/setup_program.py @@ -0,0 +1,69 @@ +import os +from os.path import exists + +from utils.console import print_markdown, print_substep + + +def setup(): + if exists(".setup-done-before"): + print_substep( + "Setup was already done before! Please make sure you have " + + "to run this script again.", style_="bold red" + ) + # to run the setup script again, but in better and more convenient manner. + if str(input("\033[1mRun the script again? [y/N] > \033[0m")).strip() not in ["y", "Y"]: + print_substep("Permission denied!", style_="bold red") + raise SystemExit() + + print_markdown( + "### You're in the setup wizard. Ensure you're supposed to be here, " + + "then type yes to continue. If you're not sure, type no to quit." + ) + + # This Input is used to ensure the user is sure they want to continue. + # Again, let them know they are about to erase all other setup data. + print_substep( + "Ensure you have the following ready to enter:\n" + + "[bold green]Reddit Client ID\n" + + "Reddit Client Secret\n" + + "Reddit Username\n" + + "Reddit Password\n" + + "Reddit 2FA (yes or no)\n" + + "Opacity (range of 0-1, decimals are accepted.)\n" + + "Subreddit (without r/ or /r/)\n" + + "Theme (light or dark)[/bold green]" + + "[bold]If you don't have these, please follow the instructions in the README.md " + + "set them up.\nIf you do have these, type y or Y to continue. If you don't, " + + "go ahead and grab those quickly and come back.[/bold]" + ) + + # Begin the setup process. + + cliID = input("Client ID > ") + cliSec = input("Client Secret > ") + user = input("Username > ") + passw = input("Password > ") + twofactor = input("2FA Enabled? (yes/no) > ") + opacity = input("Opacity? (range of 0-1) > ") + subreddit = input("Subreddit (without r/) > ") + theme = input("Theme? (light or dark) > ") + + if exists(".env"): + os.remove(".env") + + with open(".env", "a", encoding="utf-8") as f: + f.write( + f'REDDIT_CLIENT_ID="{cliID}"\n' + + f'REDDIT_CLIENT_SECRET="{cliSec}"\n' + + f'REDDIT_USERNAME="{user}"\n' + + f'REDDIT_PASSWORD="{passw}"\n' + + f'REDDIT_2FA="{twofactor}"\n' + + f'THEME="{theme}"\n' + + f'SUBREDDIT="{subreddit}"\n' + + f'OPACITY="{opacity}"\n' + ) + + with open(".setup-done-before", "a", encoding="utf-8") as f: + f.write("This file will stop the setup assistant from running again.") + + print_substep("[bold green]Setup Complete![/bold green]") diff --git a/utils/console.py b/utils/console.py index 5a041ec..5e24982 100644 --- a/utils/console.py +++ b/utils/console.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 from rich.console import Console from rich.markdown import Markdown from rich.padding import Padding @@ -7,6 +6,7 @@ from rich.text import Text from rich.columns import Columns import re + console = Console() @@ -24,10 +24,11 @@ def print_step(text): console.print(panel) -def print_substep(text, style=""): +def print_substep(text, style_=None): """Prints a rich info message without the panelling.""" - console.print(text, style=style) - + if style_ is not None: + console.print(text, style=style_) + console.print(text) def print_table(items): """Prints items in a table.""" @@ -74,3 +75,5 @@ def handle_input( console.print("[red]" + err_message) return user_input + + diff --git a/video_creation/background.py b/video_creation/background.py index ebf369d..26795ed 100644 --- a/video_creation/background.py +++ b/video_creation/background.py @@ -2,13 +2,18 @@ import random from os import listdir, environ from pathlib import Path from random import randrange +import shutil from pytube import YouTube +import re +import os +from random import randrange from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip from moviepy.editor import VideoFileClip + from utils.console import print_step, print_substep -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 beckground of the video. Args: @@ -17,56 +22,76 @@ def get_start_and_end_times(video_length:int, length_of_clip:int)->tuple[int,int Returns: tuple[int,int]: Start and end time of the randomized interval - """ + """ random_time = randrange(180, int(length_of_clip) - int(video_length)) return random_time, random_time + video_length -def download_background(): - """Downloads the backgrounds/s video from YouTube.""" +def download_background(background: str): + """Downloads given link, or if it's a path, copies that file over to assets/backgrounds/ + + Args: + background (str): Youtube link or file path + """ + yt_id_pattern = re.compile( + pattern=r'(?:https?:\/\/)?(?:[0-9A-Z-]+\.)?(?:youtube|youtu|youtube-nocookie)\.(?:com|be)\/(?:watch\?v=|watch\?.+&v=|embed\/|v\/|.+\?v=)?([^&=\n%\?]{11})') + 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}" + background_options = { + "https://www.youtube.com/watch?v=n_Dv4JMiwK8", + # "https://www.youtube.com/watch?v=2X9QGY__0II", + } + + if Path(background).is_file(): # If background is a file + shutil.copyfile(background, 'assets/backgrounds') + + # If background has a youtube video id, so if it's a youtube link + elif re.findall(yt_id_pattern, background, re.IGNORECASE): + + background_options.add(background) + + # note: make sure the file name doesn't include an - in it + if 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 link in background_options: + + filename = re.match(yt_id_pattern, link, re.IGNORECASE).string + + if not Path(f"assets/backgrounds/{filename}").is_file(): + print_substep(f"Downloading {filename} from {link}") + YouTube(link).streams.filter(res="1080p").first().download( + "assets/backgrounds", filename=f"{filename}" + ) + + print_substep( + "Background videos downloaded successfully! 🎉", style="bold green" ) + os.remove("assets/mp4/background.mp4") - print_substep( - "Background videos downloaded successfully! 🎉", style="bold green" - ) + else: + raise Exception("You didn't input a proper link into -b") -def chop_background_video(video_length:int): +def chop_background_video(video_length: int): """Generates the background footage to be used in the video and writes it to assets/temp/background.mp4 Args: 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] background = VideoFileClip(f"assets/backgrounds/{choice}") - start_time, end_time = get_start_and_end_times(video_length, background.duration) + start_time, end_time = get_start_and_end_times( + video_length, background.duration) ffmpeg_extract_subclip( f"assets/backgrounds/{choice}", start_time, diff --git a/video_creation/final_video.py b/video_creation/final_video.py index 5b2eee6..47663fb 100755 --- a/video_creation/final_video.py +++ b/video_creation/final_video.py @@ -4,6 +4,9 @@ import os import time from os.path import exists +import os +import re + from moviepy.editor import ( VideoFileClip, AudioFileClip, @@ -25,7 +28,7 @@ console = Console() W, H = 1080, 1920 -def make_final_video(number_of_clips:int, length:int): +def make_final_video(number_of_clips:int, length:int,final_vid_path:str): """Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp Args: @@ -39,17 +42,39 @@ def make_final_video(number_of_clips:int, length:int): background_clip = ( VideoFileClip("assets/temp/background.mp4") .without_audio() - .resize(height=H) + .resize(height=1920) .crop(x1=1166.6, y1=0, x2=2246.6, y2=1920) ) + try: + opacity = float(os.getenv("OPACITY")) + except ( + ValueError, + FloatingPointError, + TypeError + ): + print_substep( + "Please ensure that OPACITY is between 0 and 1 in .env file", style_="bold red" + ) + # 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.insert(0, AudioFileClip("assets/temp/mp3/title.mp3")) - audio_concat = concatenate_audioclips(audio_clips) - audio_composite = CompositeAudioClip([audio_concat]) + audio_clips.append(AudioFileClip(f"assets/mp3/{i}.mp3")) + + audio_clips.insert(0, AudioFileClip("assets/mp3/title.mp3")) + try: + audio_clips.insert(1, AudioFileClip("assets/mp3/posttext.mp3")) + except ( + OSError, + FileNotFoundError, + ): + print_substep("An error occured! Aborting.", style_="bold red") + raise SystemExit() + else: + 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]) @@ -116,11 +141,16 @@ def make_final_video(number_of_clips:int, length:int): image_concat.audio = audio_composite final = CompositeVideoClip([background_clip, image_concat]) + if final_vid_path is None: + final_vid_path = re.sub( + "[?\"%*:|<>]/", "", (f"assets/{subreddit.submission.title}.mp4") + ) + + final.write_videofile(final_vid_path, fps=30, audio_codec="aac", audio_bitrate="192k") - filename = f"{get_video_title()}.mp4" - save_data(filename) + save_data(final_vid_path) if not exists("./results"): print_substep("the results folder didn't exist so I made it") @@ -134,7 +164,7 @@ def make_final_video(number_of_clips:int, length:int): verbose=False, ) ffmpeg_tools.ffmpeg_extract_subclip( - "assets/temp/temp.mp4", 0, length, targetname=f"results/{filename}" + "assets/temp/temp.mp4", 0, length, targetname=f"results/{final_vid_path}" ) # os.remove("assets/temp/temp.mp4") @@ -178,4 +208,4 @@ def get_video_title() -> str: if len(title) <= 35: return title else: - return title[0:30] + "..." \ No newline at end of file + return title[0:30] + "..." diff --git a/video_creation/screenshot_downloader.py b/video_creation/screenshot_downloader.py index 4620677..4c5822b 100644 --- a/video_creation/screenshot_downloader.py +++ b/video_creation/screenshot_downloader.py @@ -9,11 +9,16 @@ from rich.progress import track from utils.console import print_step, print_substep import json +from pathlib import Path + +from playwright.sync_api import sync_playwright, ViewportSize +from rich.progress import track from rich.console import Console import translators as ts console = Console() +from utils.console import print_step, print_substep storymode = False @@ -31,11 +36,11 @@ def download_screenshots_of_reddit_posts(reddit_object:dict[str], screenshot_num # ! Make sure the reddit screenshots folder exists Path("assets/temp/png").mkdir(parents=True, exist_ok=True) - with sync_playwright() as p: - print_substep("Launching Headless Browser...") + with console.status("[bold]Launching Headless Browser ...", spinner="simpleDots"): + with sync_playwright() as p: + browser = p.chromium.launch() + context = browser.new_context() - browser = p.chromium.launch() - context = browser.new_context() if getenv("THEME").upper() == "DARK": cookie_file = open("./video_creation/data/cookie-dark-mode.json") diff --git a/video_creation/voices.py b/video_creation/voices.py index f5ead42..e88c98c 100644 --- a/video_creation/voices.py +++ b/video_creation/voices.py @@ -1,4 +1,6 @@ -#!/usr/bin/env python + + +from utils.console import print_step, print_substep import os