├── .envTEMPLATE ├── .gitignore ├── README.md ├── argument_parser.py ├── client_secretsTEMPLATE.json ├── main.py ├── requirements.txt └── uploader.py /.envTEMPLATE: -------------------------------------------------------------------------------- 1 | # Specify the path where your clone of the RedditVideoMakerBot repository is 2 | # Examples: 3 | ## On Windows it would be REDDIT_VIDEO_MAKER_BOT_DIR="C:\Users\User\Programs\RedditVideoMakerBot 4 | ## On Linux Distributions REDDIT_VIDEO_MAKER_BOT_DIR="/home/User/Programs/RedditVideoMakerBot" 5 | REDDIT_VIDEO_MAKER_BOT_DIR="PATH GOES HERE" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | .idea/ 165 | 166 | # End of https://www.toptal.com/developers/gitignore/api/python 167 | 168 | #YouTube Data API 169 | client_secrets.json 170 | 171 | #oauth2client 172 | main.py-oauth2.json 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reddit-Shorts-Bot 2 | A program to upload auto generated videos from Reddit threads to YouTube via the YouTube Data API v3 3 | 4 | This is an improved version of a prior project where I had slightly modified the RedditVideoMakerBot to enable automatic uploading. The major improvement is that no alterations have to be made to the source code of the RedditVideoMakerBot, so this program can still be used even as that repository is updated further, provided the current directory structure is kept. 5 | 6 | # Requirements 7 | 8 | A clone of the RedditVideoMakerBot [repository](https://github.com/elebumm/RedditVideoMakerBot) is required as it will be doing the video generation 9 | 10 | Furthermore needed are following packages: 11 | - oauth2client 12 | - google-api-python-client 13 | - httplib2 14 | - python-dotenv 15 | - tomli 16 | 17 | These are also listed in requirements.txt and can be installed with `pip install -r requirements.txt` 18 | 19 | # Setup 20 | 21 | 1. Clone the RedditVideoMakerBot [repository](https://github.com/elebumm/RedditVideoMakerBot) 22 | 2. Follow the installation guide for the RedditVideoMakerBot 23 | 3. Test the RedditVideoMakerBot to make sure it works 24 | 4. Rename `.envTEMPLATE` to `.env` and specify the path to your clone of RedditVideoMakerBot within 25 | 5. - Create a project in the Google Cloud using the YouTube Data API v3 [^1] 26 | - Rename `client_secretsTEMPLATE.json` to `client_secrets.json` and configure the marked fields with the information from your Google Cloud project 27 | 6. Run the program, on first usage, you will be asked to authorize the project by Google for a Google (YouTube) account 28 | 29 | *sidenote: the o-auth token created in step 6 of will expire every so often, meaning when this happens you will be prompted to reauthorize your google account* 30 | 31 | [^1]: The individual steps for 5. haven't been explained in detail as that would be complicated to follow. 32 | For a good explanation of the individual steps I recommend [this](https://www.youtube.com/watch?v=aFwZgth790Q) video from 4:17 to 11:05. 33 | Once you have your client ID and client secret, you can configure your `client_secrets.json` like in the next substep. 34 | 35 | # Usage 36 | 37 | Navigate to your clone of the repository and in the command line execute : 38 | ``` 39 | $ python main.py 40 | ``` 41 | The standard title of the uploaded video will be `r/[SUBREDDIT]: THREAD_TITLE`. For example `r/AskReddit: What profession do you find unhealthy`. If you want to specify a title other than the auto generated one you can specify it like this: 42 | ``` 43 | $ python main.py "title" 44 | ``` 45 | Make sure to use quotation marks ("This is a title") if you want to include spaces in your title 46 | 47 | Occasionally after successful video creation the upload / verification process will fail, in this case you can re-call the program like this: 48 | ``` 49 | $ python main.py --retry 50 | ``` 51 | to attempt to upload the latest video again, this will avoid having to go through the slow video creation process again. 52 | 53 | # Notes 54 | 55 | The upload happens through the YouTube Data API v3, after setting your account up in the google cloud you have an API quota of 10,000 / Day. Uploading a video costs 1600 points, so in a day you can upload a maximum of 6 videos. -------------------------------------------------------------------------------- /argument_parser.py: -------------------------------------------------------------------------------- 1 | from uploader import VALID_PRIVACY_STATUSES 2 | from oauth2client.tools import argparser 3 | 4 | 5 | def parse_args(): 6 | argparser.add_argument("--file", help="This parameter is set automatically") 7 | argparser.add_argument("title", nargs='?', help="Video title") 8 | argparser.add_argument("--description", help="Video description", default="") 9 | argparser.add_argument("--category", default="22", 10 | help="Numeric video category. " + 11 | "See https://developers.google.com/youtube/v3/docs/videoCategories/list") 12 | argparser.add_argument("--keywords", help="Video keywords, comma separated", 13 | default="") 14 | argparser.add_argument("--privacyStatus", choices=VALID_PRIVACY_STATUSES, 15 | default=VALID_PRIVACY_STATUSES[0], help="Video privacy status.") 16 | argparser.add_argument("--retry", help="Skip video creation and attempt to upload latest video", 17 | action="store_true") 18 | 19 | return argparser.parse_args() 20 | -------------------------------------------------------------------------------- /client_secretsTEMPLATE.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "client_id": "Client id goes here", 4 | "client_secret": "Client secret goes here", 5 | "redirect_uris": [], 6 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 7 | "token_uri": "https://accounts.google.com/o/oauth2/token" 8 | } 9 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import argument_parser 4 | from uploader import get_authenticated_service, initialize_upload 5 | from googleapiclient.errors import HttpError 6 | from dotenv import load_dotenv 7 | from pathlib import Path 8 | 9 | try: 10 | import tomllib 11 | except ModuleNotFoundError: 12 | import tomli as tomllib 13 | 14 | load_dotenv() 15 | RVBM_DIR = os.getenv('REDDIT_VIDEO_MAKER_BOT_DIR') 16 | RVBM_RESULTS_DIR_NAME = "results" 17 | 18 | 19 | def get_run_times(): 20 | config_path = Path(RVBM_DIR + '/' + 'config.toml') 21 | with open(config_path, mode="rb") as fp: 22 | config = tomllib.load(fp) 23 | 24 | times_to_run = 1 25 | 26 | try: 27 | times_to_run = config["settings"]["times_to_run"] 28 | except KeyError: 29 | print(f"times_to_run not found, using default value: {times_to_run}") 30 | 31 | return times_to_run 32 | 33 | 34 | def create_videos(): 35 | main_path = Path(RVBM_DIR + '/' + 'main.py') 36 | 37 | original_dir = os.getcwd() 38 | os.chdir(RVBM_DIR) 39 | proc = subprocess.run(["python", main_path]) 40 | proc.check_returncode() 41 | os.chdir(original_dir) 42 | 43 | 44 | def get_list_of_newest_files(path, length: int = 1): 45 | list_of_paths = path.glob('**/*.mp4') 46 | latest_videos = sorted(list_of_paths, key=lambda x: x.stat().st_ctime, reverse=True)[:length] 47 | return [Path(x) for x in latest_videos] 48 | 49 | 50 | def set_video_options(args, file_path): 51 | if args.title is None: 52 | full_title = "r/" + file_path.parent.stem + ":" + " " + file_path.stem 53 | truncated_title = (full_title[:97] + '..') if len(full_title) > 100 else full_title 54 | args.title = truncated_title 55 | return args 56 | 57 | 58 | def upload_from_path(args, file_path): 59 | args = set_video_options(args, file_path) 60 | args.file = file_path 61 | if not args.file.exists(): 62 | exit("Couldn't find video to upload!") 63 | youtube = get_authenticated_service(args) 64 | try: 65 | initialize_upload(youtube, args) 66 | except HttpError as e: 67 | print("An HTTP error %d occurred:\n%s" % (e.resp.status, e.content)) 68 | 69 | 70 | def main(): 71 | args = argument_parser.parse_args() 72 | 73 | # verify before attempting to upload to discover missing / expired o-auth key early 74 | get_authenticated_service(args) 75 | 76 | times_to_run = get_run_times() 77 | results_path = Path(RVBM_DIR + '/' + RVBM_RESULTS_DIR_NAME) 78 | 79 | if args.retry: 80 | upload_from_path(args, get_list_of_newest_files(results_path)[0]) 81 | return 82 | 83 | try: 84 | create_videos() 85 | except subprocess.CalledProcessError: 86 | print("Video Creation Process failed!") 87 | return 88 | 89 | for x in range(times_to_run): 90 | file_path = get_list_of_newest_files(results_path, times_to_run)[x] 91 | upload_from_path(args, file_path) 92 | 93 | 94 | if __name__ == '__main__': 95 | main() 96 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | google-api-python-client~=2.53.0 2 | httplib2==0.20.4 3 | oauth2client==4.1.3 4 | python-dotenv==0.20.0 5 | tomli~=2.0.1 6 | -------------------------------------------------------------------------------- /uploader.py: -------------------------------------------------------------------------------- 1 | import httplib2 2 | import os 3 | import random 4 | import sys 5 | import time 6 | 7 | from apiclient.discovery import build 8 | from apiclient.errors import HttpError 9 | from apiclient.http import MediaFileUpload 10 | from oauth2client.client import flow_from_clientsecrets 11 | from oauth2client.file import Storage 12 | from oauth2client.tools import argparser, run_flow 13 | 14 | # Explicitly tell the underlying HTTP transport library not to retry, since 15 | # we are handling retry logic ourselves. 16 | httplib2.RETRIES = 1 17 | 18 | # Maximum number of times to retry before giving up. 19 | MAX_RETRIES = 10 20 | 21 | # Always retry when these exceptions are raised. 22 | RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError) 23 | 24 | # Always retry when an apiclient.errors.HttpError with one of these status 25 | # codes is raised. 26 | RETRIABLE_STATUS_CODES = [500, 502, 503, 504] 27 | 28 | # The CLIENT_SECRETS_FILE variable specifies the name of a file that contains 29 | # the OAuth 2.0 information for this application, including its client_id and 30 | # client_secret. You can acquire an OAuth 2.0 client ID and client secret from 31 | # the Google API Console at 32 | # https://console.developers.google.com/. 33 | # Please ensure that you have enabled the YouTube Data API for your project. 34 | # For more information about using OAuth2 to access the YouTube Data API, see: 35 | # https://developers.google.com/youtube/v3/guides/authentication 36 | # For more information about the client_secrets.json file format, see: 37 | # https://developers.google.com/api-client-library/python/guide/aaa_client_secrets 38 | CLIENT_SECRETS_FILE = "client_secrets.json" 39 | 40 | # This OAuth 2.0 access scope allows an application to upload files to the 41 | # authenticated user's YouTube channel, but doesn't allow other types of access. 42 | YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload" 43 | YOUTUBE_API_SERVICE_NAME = "youtube" 44 | YOUTUBE_API_VERSION = "v3" 45 | 46 | # This variable defines a message to display if the CLIENT_SECRETS_FILE is 47 | # missing. 48 | MISSING_CLIENT_SECRETS_MESSAGE = """ 49 | WARNING: Please configure OAuth 2.0 50 | 51 | To make this sample run you will need to populate the client_secrets.json file 52 | found at: 53 | 54 | %s 55 | 56 | with information from the API Console 57 | https://console.developers.google.com/ 58 | 59 | For more information about the client_secrets.json file format, please visit: 60 | https://developers.google.com/api-client-library/python/guide/aaa_client_secrets 61 | """ % os.path.abspath(os.path.join(os.path.dirname(__file__), 62 | CLIENT_SECRETS_FILE)) 63 | 64 | VALID_PRIVACY_STATUSES = ("public", "private", "unlisted") 65 | 66 | 67 | def get_authenticated_service(args): 68 | flow = flow_from_clientsecrets(CLIENT_SECRETS_FILE, 69 | scope=YOUTUBE_UPLOAD_SCOPE, 70 | message=MISSING_CLIENT_SECRETS_MESSAGE) 71 | 72 | storage = Storage("%s-oauth2.json" % sys.argv[0]) 73 | credentials = storage.get() 74 | 75 | if credentials is None or credentials.invalid: 76 | credentials = run_flow(flow, storage, args) 77 | 78 | return build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, 79 | http=credentials.authorize(httplib2.Http())) 80 | 81 | 82 | def initialize_upload(youtube, options): 83 | tags = None 84 | if options.keywords: 85 | tags = options.keywords.split(",") 86 | 87 | body = dict( 88 | snippet=dict( 89 | title=options.title, 90 | description=options.description, 91 | tags=tags, 92 | categoryId=options.category 93 | ), 94 | status=dict( 95 | privacyStatus=options.privacyStatus 96 | ) 97 | ) 98 | 99 | # Call the API's videos.insert method to create and upload the video. 100 | insert_request = youtube.videos().insert( 101 | part=",".join(body.keys()), 102 | body=body, 103 | # The chunksize parameter specifies the size of each chunk of data, in 104 | # bytes, that will be uploaded at a time. Set a higher value for 105 | # reliable connections as fewer chunks lead to faster uploads. Set a lower 106 | # value for better recovery on less reliable connections. 107 | # 108 | # Setting "chunksize" equal to -1 in the code below means that the entire 109 | # file will be uploaded in a single HTTP request. (If the upload fails, 110 | # it will still be retried where it left off.) This is usually a best 111 | # practice, but if you're using Python older than 2.6 or if you're 112 | # running on App Engine, you should set the chunksize to something like 113 | # 1024 * 1024 (1 megabyte). 114 | media_body=MediaFileUpload(options.file, chunksize=-1, resumable=True) 115 | ) 116 | 117 | resumable_upload(insert_request) 118 | 119 | 120 | # This method implements an exponential backoff strategy to resume a 121 | # failed upload. 122 | def resumable_upload(insert_request): 123 | response = None 124 | error = None 125 | retry = 0 126 | while response is None: 127 | try: 128 | print("Uploading file...") 129 | status, response = insert_request.next_chunk() 130 | if response is not None: 131 | if 'id' in response: 132 | print("Video id '%s' was successfully uploaded." % response['id']) 133 | else: 134 | exit("The upload failed with an unexpected response: %s" % response) 135 | except HttpError as e: 136 | if e.resp.status in RETRIABLE_STATUS_CODES: 137 | error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status, 138 | e.content) 139 | else: 140 | raise 141 | except RETRIABLE_EXCEPTIONS as e: 142 | error = "A retriable error occurred: %s" % e 143 | 144 | if error is not None: 145 | print(error) 146 | retry += 1 147 | if retry > MAX_RETRIES: 148 | exit("No longer attempting to retry.") 149 | 150 | max_sleep = 2 ** retry 151 | sleep_seconds = random.random() * max_sleep 152 | print("Sleeping %f seconds and then retrying..." % sleep_seconds) 153 | time.sleep(sleep_seconds) 154 | --------------------------------------------------------------------------------