diff --git a/.env_sample b/.env_sample
new file mode 100644
index 0000000..255b42b
--- /dev/null
+++ b/.env_sample
@@ -0,0 +1,12 @@
+# reddit credentials
+REDDIT_CLIENT_SECRET=""
+REDDIT_CLIENT_ID=""
+REDDIT_USER_AGENT=""
+
+# Magick binaries path
+# Change it, it may be different
+IMAGEMAGICK_BINARY="C:\Program Files\ImageMagick-7.1.0-Q16-HDRI\magick.exe"
+
+# Google
+GOOGLE_CLIENT_ID=""
+GOOGLE_CLIENT_SECRET=""
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b9e4ca9..a235a35 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,243 @@
-/env
+
+# Created by https://www.toptal.com/developers/gitignore/api/pycharm+all,python
+# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all,python
+
+### PyCharm+all ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### PyCharm+all Patch ###
+# Ignores the whole .idea folder and all .iml files
+# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
+
+.idea/
+
+# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
+
+*.iml
+modules.xml
+.idea/misc.xml
+*.ipr
+
+# Sonarlint plugin
+.idea/sonarlint
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
.env
-/data
-__pycache__
-client_secrets.json
-*.json
-*.mp4
-video.mp4
\ No newline at end of file
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# specific to project
+data/
+moviepy-master/
+videos/
+dump.txt
+main.py-oauth2.json
+# End of https://www.toptal.com/developers/gitignore/api/pycharm+all,python
diff --git a/MovieMaker/MovieMaker.py b/MovieMaker/MovieMaker.py
new file mode 100644
index 0000000..6ada8d8
--- /dev/null
+++ b/MovieMaker/MovieMaker.py
@@ -0,0 +1,111 @@
+import os.path
+import platform
+import random
+from pathlib import Path
+from secrets import token_hex
+from typing import List
+
+from environs import Env
+from moviepy.audio.AudioClip import CompositeAudioClip
+from moviepy.editor import ImageSequenceClip, VideoFileClip, concatenate_videoclips, TextClip, AudioFileClip
+from moviepy.video.compositing.CompositeVideoClip import CompositeVideoClip
+
+from RedditDownloaderExceptions import MissingImageMagickBinariesException, MissingMP3FilesInMusicsDir
+from .utils import Utils
+
+
+class CreateMovie(Utils):
+ def __init__(self, env: Env, base_path: str):
+ Utils.__init__(self)
+ self.__env = env
+ self.__path = base_path
+ self.__music_path = os.path.join(self.__path, "musics\\")
+ self.__video_path = os.path.join(self.__path, "videos\\")
+ if not os.path.isdir(self.__video_path):
+ os.mkdir(self.__video_path)
+
+ if not os.path.isdir(self.__music_path):
+ os.mkdir(self.__music_path)
+
+ def _create_video(self, submission_data: List[dict]) -> str:
+
+ # check if magick binaries exists if on windows
+ if platform.system() == "Windows" and (
+ not self.__env("IMAGEMAGICK_BINARY") or not os.path.isfile(self.__env("IMAGEMAGICK_BINARY"))):
+ raise MissingImageMagickBinariesException(self.__env("IMAGEMAGICK_BINARY"))
+
+ # check if there's music inside the musics dir
+ if not list(Path(self.__music_path).rglob("*.mp3")):
+ raise MissingMP3FilesInMusicsDir(self.__music_path)
+
+ clips = []
+ for submission in submission_data:
+ if "gif" not in submission["image_path"][-3:]:
+ clip = ImageSequenceClip([submission["image_path"]], durations=[12])
+ clips.append(clip)
+ continue
+ clip_lengthener = [VideoFileClip(submission["image_path"])] * 60
+ clip = concatenate_videoclips(clip_lengthener).subclip(0, 12)
+ clips.append(clip)
+
+ clip = concatenate_videoclips(clips).subclip(0, 60)
+ colors = ['yellow', 'LightGreen', 'LightSkyBlue', 'LightPink4', 'SkyBlue2', 'MintCream', 'LimeGreen',
+ 'WhiteSmoke', 'HotPink4', 'PeachPuff3', 'OrangeRed3', 'silver']
+ random.shuffle(colors)
+
+ text_clips = []
+ notification_sounds = []
+
+ for i, post in enumerate(submission_data):
+ return_comment, return_count = self._add_return_comment(post['Best_comment'])
+
+ txt = TextClip(return_comment, font='Courier',
+ fontsize=38, color=colors.pop(), bg_color='black')
+ txt = txt.on_color(col_opacity=.3)
+ txt = txt.set_position((5, 500))
+ txt = txt.set_start((0, 3 + (i * 12))) # (min, s)
+ txt = txt.set_duration(7)
+ txt = txt.crossfadein(0.5)
+ txt = txt.crossfadeout(0.5)
+ text_clips.append(txt)
+
+ return_comment, _ = self._add_return_comment(post['best_reply'])
+
+ txt = TextClip(return_comment, font='Courier',
+ fontsize=38, color=colors.pop(), bg_color='black')
+ txt = txt.on_color(col_opacity=.3)
+ txt = txt.set_position((15, 585 + (return_count * 50)))
+ txt = txt.set_start((0, 5 + (i * 12))) # (min, s)
+ txt = txt.set_duration(7)
+ txt = txt.crossfadein(0.5)
+ txt = txt.crossfadeout(0.5)
+ text_clips.append(txt)
+
+ notification = AudioFileClip(os.path.join(self.__music_path, f"notification.mp3"))
+ notification = notification.set_start((0, 3 + (i * 12)))
+ notification_sounds.append(notification)
+ notification = AudioFileClip(os.path.join(self.__music_path, f"notification.mp3"))
+ notification = notification.set_start((0, 5 + (i * 12)))
+ notification_sounds.append(notification)
+
+ music_file = os.path.join(self.__music_path, f"music{random.randint(0, 4)}.mp3")
+ music = AudioFileClip(music_file)
+ music = music.set_start((0, 0))
+ music = music.volumex(.4)
+ music = music.set_duration(59)
+
+ new_audioclip = CompositeAudioClip([music] + notification_sounds)
+ filename = token_hex(4)
+ filename_clips = filename + "_clips.mp4"
+ filename += ".mp4"
+ clip.write_videofile(f"{self.__video_path}{filename_clips}", fps=24)
+
+ clip = VideoFileClip(f"{self.__video_path}{filename_clips}", audio=False)
+ clip = CompositeVideoClip([clip] + text_clips)
+ clip.audio = new_audioclip
+ clip.write_videofile(f"{self.__video_path}{filename}", fps=24)
+
+ if os.path.exists(os.path.join(self.__video_path, f"{filename_clips}")):
+ os.remove(os.path.join(self.__video_path, f"{filename_clips}"))
+
+ return self.__video_path + filename
diff --git a/MovieMaker/__init__.py b/MovieMaker/__init__.py
new file mode 100644
index 0000000..4d7270e
--- /dev/null
+++ b/MovieMaker/__init__.py
@@ -0,0 +1 @@
+from .MovieMaker import CreateMovie
diff --git a/MovieMaker/utils.py b/MovieMaker/utils.py
new file mode 100644
index 0000000..4f3c5b5
--- /dev/null
+++ b/MovieMaker/utils.py
@@ -0,0 +1,32 @@
+from typing import Tuple
+
+
+class Utils(object):
+ def __init__(self):
+ pass
+
+ @staticmethod
+ def _get_day_suffix(day: int) -> str:
+ if day == 1 or day == 21 or day == 31:
+ return "st"
+ elif day == 2 or day == 22:
+ return "nd"
+ elif day == 3 or day == 23:
+ return "rd"
+ else:
+ return "th"
+
+ @staticmethod
+ def _add_return_comment(comment: str) -> Tuple[str, int]:
+ need_return = 30
+ new_comment = ""
+ return_added = 0
+ if comment:
+ return_added += comment.count('\n')
+ for i, letter in enumerate(comment):
+ if i > need_return and letter == " ":
+ letter = "\n"
+ need_return += 30
+ return_added += 1
+ new_comment += letter
+ return new_comment, return_added
diff --git a/Publishers/YoutubePublisher.py b/Publishers/YoutubePublisher.py
new file mode 100644
index 0000000..8e17704
--- /dev/null
+++ b/Publishers/YoutubePublisher.py
@@ -0,0 +1,149 @@
+import json
+import os
+import random
+import sys
+import time
+from secrets import token_hex
+
+import httplib2
+from environs import Env
+from googleapiclient.discovery import build
+from googleapiclient.errors import HttpError
+from googleapiclient.http import MediaFileUpload
+from oauth2client.client import flow_from_clientsecrets
+from oauth2client.file import Storage
+from oauth2client.tools import run_flow
+
+
+class YtbPublisher(object):
+ httplib2.RETRIES = 1
+ __MAX_RETRIES = 10
+ __RETRIABLE_STATUS_CODES = [500, 502, 503, 504]
+ __RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError)
+ __YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload"
+ __YOUTUBE_API_SERVICE_NAME = "youtube"
+ __YOUTUBE_API_VERSION = "v3"
+ __MISSING_CLIENT_SECRETS_MESSAGE = "WRONG CREDENTIALS FOR THE YOUTUBE API"
+ __VALID_PRIVACY_STATUSES = ("public", "private", "unlisted")
+
+ def __init__(self, env: Env):
+ self.__env = env
+ self.__json_secret = ""
+ if not env("GOOGLE_CLIENT_ID") or not env("GOOGLE_CLIENT_SECRET"):
+ print("WARNING : Google credentials not added to .env . If you use "
+ ".publish_on(SocialMedias.YouTube) it will raise an error !")
+
+ def __get_authenticated_service(self):
+ flow = flow_from_clientsecrets(self.__json_secret,
+ scope=self.__YOUTUBE_UPLOAD_SCOPE,
+ message=self.__MISSING_CLIENT_SECRETS_MESSAGE)
+
+ storage = Storage("%s-oauth2.json" % sys.argv[0])
+ credentials = storage.get()
+
+ if credentials is None or credentials.invalid:
+ credentials = run_flow(flow, storage)
+
+ return build(self.__YOUTUBE_API_SERVICE_NAME, self.__YOUTUBE_API_VERSION,
+ http=credentials.authorize(httplib2.Http()))
+
+ def __initialize_upload(self, youtube, options):
+ tags = None
+ # if options.keywords:
+ # tags = options.keywords.split(",")
+
+ body = dict(
+ snippet=dict(
+ title=options['title'],
+ description=options['description'],
+ tags=tags,
+ # categoryId=options['category']
+ ),
+ status=dict(
+ privacyStatus=options['privacyStatus']
+ )
+ )
+
+ # Call the API's videos.insert method to create and upload the video.
+ insert_request = youtube.videos().insert(
+ part=",".join(body.keys()),
+ body=body,
+ media_body=MediaFileUpload(options['file'], chunksize=-1, resumable=True)
+ )
+
+ self.__resumable_upload(insert_request)
+
+ # This method implements an exponential backoff strategy to resume a
+ # failed upload.
+ def __resumable_upload(self, insert_request):
+ response = None
+ error = None
+ retry = 0
+ while response is None:
+ try:
+ print("Uploading file...")
+ status, response = insert_request.next_chunk()
+ if response is not None:
+ if 'id' in response:
+ print("Video id '%s' was successfully uploaded." % response['id'])
+ else:
+ exit("The upload failed with an unexpected response: %s" % response)
+ except HttpError as e:
+ if e.resp.status in self.__RETRIABLE_STATUS_CODES:
+ error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status,
+ e.content)
+ else:
+ raise
+ except self.__RETRIABLE_EXCEPTIONS as e:
+ error = "A retriable error occurred: %s" % e
+
+ if error is not None:
+ print(error)
+ retry += 1
+ if retry > self.__MAX_RETRIES:
+ exit("No longer attempting to retry.")
+
+ max_sleep = 2 ** retry
+ sleep_seconds = random.random() * max_sleep
+ print("Sleeping %f seconds and then retrying..." % sleep_seconds)
+ time.sleep(sleep_seconds)
+
+ def _youtube(self, video_data):
+ if not os.path.exists(video_data['file']):
+ exit("Please specify a valid file using the file= parameter in the data passed to .publish_on().")
+
+ client_json = {
+ "web": {
+ "client_id": self.__env("GOOGLE_CLIENT_ID"),
+ "client_secret": self.__env("GOOGLE_CLIENT_SECRET"),
+ "redirect_uris": [],
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://accounts.google.com/o/oauth2/token"
+ }
+ }
+ self.__json_secret = f"{token_hex(4)}.json"
+ with open(self.__json_secret, encoding="utf-8", mode="w") as f:
+ json.dump(client_json, f)
+
+ youtube = self.__get_authenticated_service()
+ try:
+ self.__initialize_upload(youtube, video_data)
+
+ except HttpError as e:
+ print("An HTTP error %d occurred:\n%s" % (e.resp.status, e.content))
+ if os.path.isfile(self.__json_secret):
+ os.remove(self.__json_secret)
+
+
+if __name__ == "__main__":
+ env = Env()
+ env.read_env()
+ ytb = YtbPublisher(env)
+ data = {
+ "file": "C:\\Users\\julien.gunther\\PycharmProjects\\RedditDownloader\\videos\\67f2c04d.mp4",
+ "title": "#shorts \n Memes but this time you laugh for real",
+ "description": "why tho",
+ "keywords": "meme,memes,laugh,internet,short",
+ "privacyStatus": "private"
+ }
+ ytb._youtube(data)
diff --git a/Publishers/__init__.py b/Publishers/__init__.py
new file mode 100644
index 0000000..18b723c
--- /dev/null
+++ b/Publishers/__init__.py
@@ -0,0 +1 @@
+from .YoutubePublisher import YtbPublisher
diff --git a/README.md b/README.md
index 7bf74e3..4b2febc 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,259 @@
-
-# Fully Automated YouTube Shorts Channel
-> This code will show you how to setup and fully autmated YouTube Channel.
-> Content is gathered from Reddit using images both animated and still images will work.
-> Setup is in depth at times so check out the tutorial video if you need extra help.
-
-## Setup
-- Clone Project
-- pip install -r requirements.txt
-- This project uses MoviePy which uses ImageMagick to write text onto our movie. Follow the link to install. Not on windows you will need to update config defaults.
-- create a .env file with your reddit API details.
-- create a client_secrets.json for your YouTube API details.
-- Both are covered in the video if you need help!
-
-## Contact!
-- YouTube Clarity Coders
-- Chat with me! Discord
+# Reddit Image Downloader and publisher
+
+## A follow up to [ClarityCoders' AutoTube](https://github.com/ClarityCoders/AutoTube)
+
+### Requirements
+
+You need a reddit application for that. You can create one to the following link :https://www.reddit.com/prefs/apps
+
+You can follow this tutorial to create your application : https://youtu.be/bMT9ZC9sBzI?t=228
+
+### Installation steps
+
+1. Clone this repository
+2. Install [ImageMagick](https://www.imagemagick.org/script/index.php)
+3. Rename the [.env_sample](.env_sample) file to .env
+4. Edit your .env file :
+ - REDDIT_CLIENT_SECRET="YourClientSecret"
+ - REDDIT_CLIENT_ID="YourClientId"
+ - REDDIT_USER_AGENT=""
+ - IMAGEMAGICK_BINARIES="C:\Program Files\ImageMagick-7.1.0-Q16-HDRI\magick.exe" (Change the path to your
+ installation to the magick.exe file. Check [moviepy on pypi](https://pypi.org/project/moviepy/) for more
+ information)
+
+The `IMAGEMAGICK_BINARIES` environment variable is only needed if you're on Windows or on Ubuntu 16.04LTS
+
+An example for the reddit user agent : ""
+
+4. open a console (bash, cmd, etc...) where you cloned the repo and enter the following command :
+
+`pip install -r requirements.txt`
+
+If it returns an error, try the following command :
+
+`pip3 install -r requirements.txt`
+
+Is it still doesn't work, make sure you that python and pip are properly installed.
+
+## How to use
+
+Some code will be more explicit :
+
+````python
+from RedditDownloader import RedditBot
+from utils import SocialMedias, get_credentials, Scales
+
+reddit = RedditBot(get_credentials())
+data = reddit.save_images_from_subreddit(
+ amount=5,
+ subreddits=("dankmemes",),
+ scale=Scales.InstagramPhotoSquare
+)
+video_path = reddit.create_video(data)
+
+ytb_data = {
+ "file": video_path,
+ "title": "#shorts \n Memes but this time you laugh for real",
+ "description": "why tho",
+ "keywords": "meme,memes,laugh,internet,shorts",
+ "privacyStatus": "public"
+}
+
+reddit.publish_on(SocialMedias.YouTube, ytb_data)
+
+````
+
+## RedditBot
+
+This class takes 2 arguments :
+
+- required : `env` - `environs.Env` instance that has been initialized. There's a utility function for that :
+ `utils.get_credentials()`
+- optional : `log` - `True` to log to console operation, `False` by default
+
+You then have a single method described below :
+
+## RedditBot.save_images_from_subreddit()
+
+All 6 keyword arguments are optional.
+
+- `subreddits` - a tuple of strings that contains the subreddit names. By default, it will query the
+ [memes](https://www.reddit.com/r/memes/) subreddit. Check out what's after the r/ to known what is the exact string to
+ add to the tuple.
+
+
+- `amount` - how many images (posts) do you want to download (int). By default, 5.
+
+
+- `filetypes` - A tuple that contains all the file extensions that you want to download. By default,
+ ("jpg", "png", "gif"). It may be used to download only jps and png or only gif. It may raise errors with other file
+ extensions.
+
+
+- `nsfw` - bool. If set to True it will only download NSFW posts (marked NSFW by the community or mods). If set to False
+ it will only download SFW posts. By default, set to False.
+
+
+- `scale` - tuple of ints. If passed, it will resize the downloaded images with the size passed as argument
+ (width, height). By default, None is passed so no resize occurs. Some sizes are already defined for TikTok, YouTube or
+ Instagram. -> see [Scales](#scales)
+
+
+- `replace_resized` - bool. Only works when a new scale is passed. If set to False the resized image will be placed in
+ a "resized" folder along with the original images. If set to True it will replace the original images.
+
+This method will return a `list of dict` with the data queried that you can use later on.
+
+## RedditBot.create_video()
+
+This method will create a video with the data previously queried. it takes an optional argument :
+
+- video_data : that correspond to the data queried. If no argument are passed, it will take the last data queried if
+ still in memory. If no argument are passed and there is no data left in memory, it will raise an exception.
+
+It returns the path to the created video.
+
+## RedditBot.get_path_images()
+
+This method will return the path of the queried images. it takes one optional argument :
+
+- data : that correspond to the data queried. If no argument are passed, it will take the last data queried if still in
+ memory. If no argument are passed and there is no data left in memory, it will raise an exception.
+
+## RedditBot.publish_on()
+
+Publish photo or video on a social media.
+
+Takes 2 required arguments :
+
+- social_media - see [SocialMedias](#SocialMedias)
+- data - a dict that contains all the data needed for the post. See [Data format](#data-format) for more information
+ about the data formatting.
+
+## Scales
+
+There's some scales already defined. To access them, import `Scales` from `utils` :
+
+````python
+from utils import Scales
+
+Scales.show_attributes()
+````
+
+````text
+['Default',
+ 'InstagramIGTVCoverPhoto',
+ 'InstagramPhotoLandscape',
+ 'InstagramPhotoPortrait',
+ 'InstagramPhotoSquare',
+ 'InstagramReels',
+ 'InstagramStories',
+ 'InstagramVideoLandscape',
+ 'InstagramVideoPortrait',
+ 'InstagramVideoSquare',
+ 'Snapchat',
+ 'TikTok',
+ 'YoutubeShortsFullscreen',
+ 'YoutubeShortsSquare',
+ 'YoutubeVideo']
+````
+
+## SocialMedias
+
+Use this class to choose a social media to posts your video/photo
+
+````python
+from utils import SocialMedias
+
+SocialMedias.show_attributes()
+````
+
+````text
+['Instagram', 'Snapchat', 'TikTok', 'YouTube']
+````
+
+usage :
+
+````python
+from RedditDownloader import RedditBot
+from utils import SocialMedias, get_credentials, Scales
+
+reddit = RedditBot(get_credentials())
+data = reddit.save_images_from_subreddit(
+ amount=5,
+ subreddits=("dankmemes",),
+ scale=Scales.InstagramPhotoSquare
+)
+video_path = reddit.create_video(data)
+
+ytb_data = {
+ "file": video_path,
+ "title": "#short \n Memes but this time you laugh for real",
+ "description": "why tho",
+ "keywords": "meme,memes,laugh,internet,short",
+ "privacyStatus": "public"
+}
+
+reddit.publish_on(SocialMedias.YouTube, ytb_data)
+
+````
+
+## Data format
+
+### for create_video()
+
+````json
+[
+ {
+ "image_path": "absolute path to image",
+ "Best_comment": "best comment on this image",
+ "best_reply": "best reply of the best comment"
+ },
+ {
+ "image_path": "absolute path to image",
+ "Best_comment": "best comment on this image",
+ "best_reply": "best reply of the best comment"
+ },
+ ...
+]
+````
+
+### for publish_on(SocialMedias.YouTube)
+````json
+
+{
+ "file": "absolute path to video",
+ "title": "video title",
+ "description": "video description",
+ "keywords": "tag1,tag2,tag3...",
+ "privacyStatus": "private|public|unlisted"
+}
+````
+
+### for publish_on(SocialMedias.Instagram)
+#### still not implemented
+````json
+{
+ "files": ["absolute path to video/photo1", "absolute path to video/photo2", ...],
+ "description": "post description",
+ "type": "igtv|reels|post"
+}
+````
+
+### for publish_on(SocialMedias.TikTok)
+#### still not implemented
+````json
+{
+ "file": "absolute path to video",
+ "description": "post description"
+}
+````
+
+### for publish_on(SocialMedias.Snapchat)
+#### still not implemented
+````json
+{
+ "file": "absolute path to video",
+ "description": "post description"
+}
+````
\ No newline at end of file
diff --git a/RedditDownloader/RedditBot.py b/RedditDownloader/RedditBot.py
new file mode 100644
index 0000000..61acb53
--- /dev/null
+++ b/RedditDownloader/RedditBot.py
@@ -0,0 +1,240 @@
+import json
+import os
+from datetime import date
+from typing import List, Tuple
+
+import praw
+import requests
+from environs import Env
+from prawcore import ResponseException
+
+from MovieMaker import CreateMovie
+from Publishers import YtbPublisher
+from RedditDownloaderExceptions import MissingRedditCredentialsException, IncorrectRedditCredentialsException
+from .ScaleImages import Scale
+
+
+class RedditBot(Scale, CreateMovie, YtbPublisher):
+ def __init__(self, env: Env, base_path=os.getcwd(), log: bool = False) -> None:
+ """Reddit downloader class
+
+ :param env: environs.Env object that already has been initialized. You can use utils.get_credentials() for that.
+ :param log: True to log operations in console, by default to False.
+ :param base_path: A string or pathlike to a folder where images and data will be downloaded (cwd by default).
+ """
+
+ # init parent classes
+ Scale.__init__(self)
+ CreateMovie.__init__(self, env, base_path)
+ YtbPublisher.__init__(self, env)
+
+ self._log = log
+
+ # Check if credentials exists
+ if not env("REDDIT_CLIENT_ID"):
+ raise MissingRedditCredentialsException("REDDIT_CLIENT_ID")
+ if not env("REDDIT_CLIENT_SECRET"):
+ raise MissingRedditCredentialsException("REDDIT_CLIENT_SECRET")
+ if not env("REDDIT_USER_AGENT"):
+ raise MissingRedditCredentialsException("REDDIT_USER_AGENT")
+
+ # connect to reddit
+ self.__reddit = praw.Reddit(
+ client_id=env("REDDIT_CLIENT_ID"),
+ client_secret=env("REDDIT_CLIENT_SECRET"),
+ user_agent=env("REDDIT_USER_AGENT")
+ )
+
+ try:
+ next(self.__reddit.subreddit("memes").top("day", limit=1))
+ except ResponseException:
+ raise IncorrectRedditCredentialsException()
+
+ # define image format that we want to query
+ self.__accepted_format = ["jpg", "png", "gif"]
+
+ # define a path with folder that has today's date
+ self.__today_data_path = os.path.join(base_path, f"data\\{date.today().strftime('%m%d%Y')}\\")
+
+ # define path to utility file like already_downloaded.json
+ self.__already_downloaded_path = os.path.join(base_path, f"data/utils/")
+
+ # define file name for already downloaded images
+ self.__already_downloaded_json = "already_downloaded.json"
+
+ # store downloaded data in a single list
+ self.__submission_data = []
+
+ # create already_downloaded.json if not exists
+ if not os.path.isdir(self.__already_downloaded_path):
+ os.makedirs(self.__already_downloaded_path, exist_ok=True)
+ with open(file=f"{self.__already_downloaded_path}{self.__already_downloaded_json}", mode="w"):
+ pass
+
+ # load json file to class
+ with open(file=f"{self.__already_downloaded_path}{self.__already_downloaded_json}", mode="r",
+ encoding="utf-8-sig") as f:
+ try:
+ self.__already_downloaded = json.loads(f.read())
+ except json.decoder.JSONDecodeError:
+ self.__already_downloaded = []
+
+ def __create_subreddit_folder(self, subreddit: str) -> str:
+
+ sub_path = os.path.join(self.__today_data_path, f"{subreddit}")
+ if not os.path.isdir(sub_path):
+ os.makedirs(sub_path, exist_ok=True)
+ os.mkdir(os.path.join(sub_path, "images"))
+ os.mkdir(os.path.join(sub_path, "data"))
+ return sub_path
+
+ def __get_posts_from_subreddit(self, subreddit: str, over_18: bool, amount: int, accepted_format: Tuple[str]) -> \
+ List[praw.reddit.models.Submission]:
+
+ submissions = []
+ for submission in self.__reddit.subreddit(subreddit).top("day", limit=1000):
+ if not submission.stickied and submission.url.lower()[-3:] in accepted_format and \
+ submission.over_18 == over_18 and submission.id not in self.__already_downloaded:
+ submissions.append(submission)
+ if len(submissions) >= amount:
+ break
+ return submissions
+
+ def __save_submission_image(self, save_path: str, submission: praw.reddit.models.Submission, scale: tuple,
+ replace_resized: bool) -> None:
+
+ img = requests.get(submission.url.lower())
+ with open(save_path, "wb") as f:
+ f.write(img.content)
+ if self._log:
+ print("Image downloaded.")
+ if scale:
+ if self._log:
+ print("Resizing image...")
+ self._scale_image(save_path, scale, replace_resized)
+
+ def __save_submission_data(self, save_path: str, image_path: str,
+ submission: praw.reddit.models.Submission) -> None:
+ submission.comment_sort = "best"
+ best_comment = None
+ best_comment_2 = None
+ best_reply = None
+
+ for comment in submission.comments:
+ if len(comment.body) <= 140 and "http" not in comment.body:
+ if not best_comment:
+ best_comment = comment
+ else:
+ best_comment_2 = comment.body
+ break
+
+ if best_comment:
+ best_comment.reply_sort = "top"
+ best_comment.refresh()
+
+ for reply in best_comment.replies:
+ if len(reply.body) >= 140 or "http" in reply.body:
+ continue
+ best_reply = reply.body
+ break
+
+ best_comment = best_comment.body
+
+ submission_data = {
+ "image_path": image_path,
+ 'id': submission.id,
+ "title": submission.title,
+ "score": submission.score,
+ "18": submission.over_18,
+ "Best_comment": best_comment,
+ "Best_comment_2": best_comment_2,
+ "best_reply": best_reply
+ }
+
+ self.__already_downloaded.append(submission.id)
+ self.__submission_data.append(submission_data)
+ with open(f"{self.__already_downloaded_path}{self.__already_downloaded_json}", mode="w",
+ encoding="utf-8-sig") as f:
+ json.dump(self.__already_downloaded, f)
+ with open(f"{save_path}", mode="w", encoding="utf-8-sig") as f:
+ json.dump(submission_data, f)
+
+ def save_images_from_subreddit(self, subreddits: Tuple[str] = ("memes",), amount: int = 5,
+ filetypes: tuple = ("jpg", "png"), nsfw: bool = False,
+ scale: tuple = None, replace_resized: bool = True) -> List[dict]:
+ """Save images from multiple subreddits.
+
+ :param subreddits: Tuple of strings that contain subreddit names (by default, will search for the /r/memes
+ subreddit)
+ :param amount: amount of posts to query by subreddit. 5 by default.
+ :param filetypes: a tuple of accepted file types. By default, : ("jpg", "png"). Warning ! Using other file
+ types than those 2 may cause exceptions. That functionality hasn't been tested. Its use is mainly to
+ restrain queries to one or two of the default types.
+ :param nsfw: True for NSFW posts only, False for SFW posts only. False by default
+ :param scale: a tuple (width: int, height: int) you can pass with the new width/height (in pixel) for each image
+ downloaded. None by default
+ :param replace_resized: used if a scale is passed. If True it replaces the images, if False it will create a
+ new directory with resized images. True by default
+ :return: data queried
+ """
+ self.__submission_data = []
+ for subreddit in subreddits:
+ save_path = self.__create_subreddit_folder(subreddit)
+ if self._log:
+ print(f"Search for images on the {subreddit} subreddit...")
+ submissions = self.__get_posts_from_subreddit(subreddit, nsfw, amount, filetypes)
+ if self._log:
+ print("Images found ! start downloading them...")
+ for submission in submissions:
+ image_path = f"{save_path}\\images\\{submission.id}{submission.url.lower()[-4:]}"
+ self.__save_submission_image(image_path, submission, scale, replace_resized)
+ self.__save_submission_data(f"{save_path}\\data\\{submission.id}.json", image_path, submission)
+ if self._log:
+ print(f"{len(submissions)} images from /r/{subreddit} have been downloaded.")
+ if self._log:
+ print(f"Download finished for the following subreddit(s) : {', '.join(subreddits)}.")
+
+ return self.__submission_data
+
+ def create_video(self, video_data: List[dict] = None):
+ """Create a video with the images previously saved from reddit.
+
+ :param video_data: Optional Video data returned by .save_images_from_subreddit. If nothing passed it will
+ use the last posts queried from reddit.
+ :return: the path to the created video
+ """
+ return self._create_video(video_data if video_data else self.__submission_data)
+
+ def get_path_images(self, data: List[dict] = None):
+ """Return a list oh paths for the data passed
+
+ :param data: optional data queried with .save_images_from_subreddit()
+ :return: a list of path
+ """
+ if not data:
+ data = self.__submission_data
+ return [i["image_path"] for i in data]
+
+ def publish_on(self, social_media: int, media_data: dict) -> None:
+ """Publish a video or an image on a social media
+
+ :param social_media: use utils.SocialMedias.TheSocialMediaYouWant
+ :param media_data: dict that contains data about an image. It can be found in "{base_path}/data/{today}/{subreddit}/data/"
+ :return: None
+ """
+ if social_media == 0:
+ self._youtube(media_data)
+ return
+
+ if social_media == 1:
+ # TODO: implement TikTok
+ print("Not implemented yet")
+ return
+
+ if social_media == 2:
+ # TODO: implement Instagram
+ print("Not implemented yet")
+ return
+
+ print("Not implemented yet")
+ # TODO: implement Snapchat
diff --git a/RedditDownloader/ScaleImages.py b/RedditDownloader/ScaleImages.py
new file mode 100644
index 0000000..f7e9120
--- /dev/null
+++ b/RedditDownloader/ScaleImages.py
@@ -0,0 +1,60 @@
+import os
+
+from PIL import Image
+
+
+class Scale(object):
+ def __init__(self):
+ pass
+
+ def _scale_image(self, path: str, scale: tuple, replace_resized: bool):
+ img = Image.open(path)
+ filename = path.split("\\")[-1]
+
+ if not replace_resized:
+ path = path.replace(filename, "resized\\")
+ if not os.path.isdir(path):
+ os.mkdir(path)
+ path += filename
+
+ if path[-3:] != "gif":
+ img = img.resize(scale)
+ img.save(path)
+ return
+
+ old_infos = {
+ "version": img.info.get("version", b"GIF01a"),
+ "loop": bool(img.info.get("loop", 1)),
+ "duration": img.info.get("duration", 40),
+ "background": img.info.get("background", 223),
+ 'extension': img.info.get('extension', b'NETSCAPE2.0'),
+ 'transparency': img.info.get('transparency', 223)
+ }
+
+ new_frames = self.__get_new_frames(img, scale)
+ self.__save_new_gif(new_frames, old_infos, path)
+
+ @staticmethod
+ def __get_new_frames(gif: Image, scale: tuple) -> list:
+ new_frames = []
+ actual_frames = gif.n_frames
+ for frame in range(actual_frames):
+ gif.seek(frame)
+ new_frame = Image.new('RGBA', gif.size)
+ new_frame.paste(gif)
+ new_frame = new_frame.resize(scale, Image.ANTIALIAS)
+ new_frames.append(new_frame)
+ return new_frames
+
+ @staticmethod
+ def __save_new_gif(frames: list, old_infos: dict, path: str):
+ frames[0].save(
+ path,
+ version=old_infos["version"],
+ append_images=frames[1:],
+ duration=old_infos['duration'],
+ loop=old_infos['loop'],
+ background=old_infos['background'],
+ extension=old_infos['extension'],
+ transparency=old_infos['transparency']
+ )
diff --git a/RedditDownloader/__init__.py b/RedditDownloader/__init__.py
new file mode 100644
index 0000000..e2fc7f9
--- /dev/null
+++ b/RedditDownloader/__init__.py
@@ -0,0 +1 @@
+from .RedditBot import RedditBot
diff --git a/RedditDownloaderExceptions/MovieMakerExceptions.py b/RedditDownloaderExceptions/MovieMakerExceptions.py
new file mode 100644
index 0000000..030a31d
--- /dev/null
+++ b/RedditDownloaderExceptions/MovieMakerExceptions.py
@@ -0,0 +1,33 @@
+class MovieMakerException(Exception):
+ """Base exception for the RedditBot class
+ """
+
+ def __init__(self, *args):
+ Exception.__init__(self, *args)
+
+
+class MissingImageMagickBinariesException(MovieMakerException):
+ """Raised when missing magick binaries
+ """
+
+ def __init__(self, path: str,
+ message: str = "Missing ImageMagick binaries or path not valid. Please complete your .env file."):
+ self.__path = path
+ self.__message = message
+ MovieMakerException.__init__(self, message)
+
+ def __str__(self):
+ return f"{self.__message} : \"{self.__path}\""
+
+
+class MissingMP3FilesInMusicsDir(MovieMakerException):
+ """Raised if the musics dir doesn't contain any mp3 file
+ """
+
+ def __init__(self, path, message: str = "Missing MP3 files in"):
+ self.__message = message
+ self.__path = path
+ MovieMakerException.__init__(self, message)
+
+ def __str__(self):
+ return f"{self.__message} {self.__path}"
diff --git a/RedditDownloaderExceptions/RedditBotExceptions.py b/RedditDownloaderExceptions/RedditBotExceptions.py
new file mode 100644
index 0000000..801db5c
--- /dev/null
+++ b/RedditDownloaderExceptions/RedditBotExceptions.py
@@ -0,0 +1,28 @@
+class RedditBotException(Exception):
+ """Base exception for the RedditBot class
+ """
+
+ def __init__(self, *args):
+ Exception.__init__(self, *args)
+
+
+class MissingRedditCredentialsException(RedditBotException):
+ """Raised when credentials for Reddit are missing
+ """
+
+ def __init__(self, credential, message="Missing reddit credentials. Please complete your .env file."):
+ self.__credential = credential
+ self.__message = message
+ RedditBotException.__init__(self, message)
+
+ def __str__(self):
+ return f"{self.__message} : \"{self.__credential}\""
+
+
+class IncorrectRedditCredentialsException(RedditBotException):
+ """Raised when reddit credentials are not correct
+ """
+
+ def __init__(self,
+ message: str = "Can't connect to reddit with current credentials. Please enter valid credentials in your .env file."):
+ RedditBotException.__init__(self, message)
diff --git a/RedditDownloaderExceptions/YoutubePublisherExceptions.py b/RedditDownloaderExceptions/YoutubePublisherExceptions.py
new file mode 100644
index 0000000..3e1e0bd
--- /dev/null
+++ b/RedditDownloaderExceptions/YoutubePublisherExceptions.py
@@ -0,0 +1,19 @@
+class YoutubePublisherException(Exception):
+ """Base exception for the RedditBot class
+ """
+
+ def __init__(self, *args):
+ Exception.__init__(self, *args)
+
+
+class MissingYouTubeCredentialsException(YoutubePublisherException):
+ """Raised when credentials for YouTube are missing
+ """
+
+ def __init__(self, credential, message="Missing youtube credentials. Please complete your .env file."):
+ self.__credential = credential
+ self.__message = message
+ YoutubePublisherException.__init__(self, message)
+
+ def __str__(self):
+ return f"{self.__message} : \"{self.__credential}\""
diff --git a/RedditDownloaderExceptions/__init__.py b/RedditDownloaderExceptions/__init__.py
new file mode 100644
index 0000000..ba7c9b8
--- /dev/null
+++ b/RedditDownloaderExceptions/__init__.py
@@ -0,0 +1,3 @@
+from .RedditBotExceptions import MissingRedditCredentialsException, IncorrectRedditCredentialsException
+from .YoutubePublisherExceptions import MissingYouTubeCredentialsException
+from .MovieMakerExceptions import MissingImageMagickBinariesException, MissingMP3FilesInMusicsDir
diff --git a/main.py b/main.py
index be55260..36dfe69 100644
--- a/main.py
+++ b/main.py
@@ -1,57 +1,16 @@
-"""
-This is the main loop file for our AutoTube Bot!
-
-Quick notes!
-- Currently it's set to try and post a video then sleep for a day.
-- You can change the size of the video currently it's set to post shorts.
- * Do this by adding a parameter of scale to the image_save function.
- * scale=(width,height)
-"""
-
-from datetime import date
-import time
-from utils.CreateMovie import CreateMovie, GetDaySuffix
-from utils.RedditBot import RedditBot
-from utils.upload_video import upload_video
-
-#Create Reddit Data Bot
-redditbot = RedditBot()
-
-# Leave if you want to run it 24/7
-while True:
-
- # Gets our new posts pass if image related subs. Default is memes
- posts = redditbot.get_posts("memes")
-
- # Create folder if it doesn't exist
- redditbot.create_data_folder()
-
- # Go through posts and find 5 that will work for us.
- for post in posts:
- redditbot.save_image(post)
-
- # Wanted a date in my titles so added this helper
- DAY = date.today().strftime("%d")
- DAY = str(int(DAY)) + GetDaySuffix(int(DAY))
- dt_string = date.today().strftime("%A %B") + f" {DAY}"
-
- # Create the movie itself!
- CreateMovie.CreateMP4(redditbot.post_data)
-
- # Video info for YouTube.
- # This example uses the first post title.
- video_data = {
- "file": "video.mp4",
- "title": f"{redditbot.post_data[0]['title']} - Dankest memes and comments {dt_string}!",
- "description": "#shorts\nGiving you the hottest memes of the day with funny comments!",
- "keywords":"meme,reddit,Dankestmemes",
- "privacyStatus":"public"
- }
-
- print(video_data["title"])
- print("Posting Video in 5 minutes...")
- time.sleep(60 * 5)
- upload_video(video_data)
-
- # Sleep until ready to post another video!
- time.sleep(60 * 60 * 24 - 1)
+from RedditDownloader import RedditBot
+from utils import SocialMedias, get_credentials
+
+reddit = RedditBot(get_credentials(), log=True)
+data = reddit.save_images_from_subreddit(amount=1)
+video_path = reddit.create_video(data)
+
+ytb_data = {
+ "file": video_path,
+ "title": "#shorts r/cursedcomments",
+ "description": "why tho",
+ "keywords": "meme,memes,laugh,internet,short,reddit",
+ "privacyStatus": "unlisted"
+}
+
+reddit.publish_on(SocialMedias.YouTube, ytb_data)
diff --git a/Music/music0.mp3 b/musics/music0.mp3
similarity index 100%
rename from Music/music0.mp3
rename to musics/music0.mp3
diff --git a/Music/music1.mp3 b/musics/music1.mp3
similarity index 100%
rename from Music/music1.mp3
rename to musics/music1.mp3
diff --git a/Music/music2.mp3 b/musics/music2.mp3
similarity index 100%
rename from Music/music2.mp3
rename to musics/music2.mp3
diff --git a/Music/music3.mp3 b/musics/music3.mp3
similarity index 100%
rename from Music/music3.mp3
rename to musics/music3.mp3
diff --git a/Music/music4.mp3 b/musics/music4.mp3
similarity index 100%
rename from Music/music4.mp3
rename to musics/music4.mp3
diff --git a/Music/notification.mp3 b/musics/notification.mp3
similarity index 100%
rename from Music/notification.mp3
rename to musics/notification.mp3
diff --git a/requierments.txt b/requierments.txt
new file mode 100644
index 0000000..d549dec
--- /dev/null
+++ b/requierments.txt
@@ -0,0 +1,5 @@
+praw~=7.5.0
+environs~=9.3.5
+pillow~=8.4.0
+moviepy~=1.0.3
+google-api-python-client~=2.31.0
diff --git a/requirements-pip-freeze.txt b/requirements-pip-freeze.txt
new file mode 100644
index 0000000..d3c6efc
Binary files /dev/null and b/requirements-pip-freeze.txt differ
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index cef3ce2..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,37 +0,0 @@
-cachetools==4.2.4
-certifi==2021.10.8
-charset-normalizer==2.0.7
-colorama==0.4.4
-decorator==4.4.2
-google-api-core==2.2.2
-google-api-python-client==2.31.0
-google-auth==2.3.3
-google-auth-httplib2==0.1.0
-google-auth-oauthlib==0.4.6
-googleapis-common-protos==1.53.0
-httplib2==0.20.2
-idna==3.3
-imageio==2.11.1
-imageio-ffmpeg==0.4.5
-moviepy==1.0.3
-numpy==1.21.4
-oauth2client==4.1.3
-oauthlib==3.1.1
-Pillow==8.4.0
-praw==7.5.0
-prawcore==2.3.0
-proglog==0.1.9
-protobuf==3.19.1
-pyasn1==0.4.8
-pyasn1-modules==0.2.8
-pyparsing==3.0.6
-python-dotenv==0.19.2
-requests==2.26.0
-requests-oauthlib==1.3.0
-rsa==4.7.2
-six==1.16.0
-tqdm==4.62.3
-update-checker==0.18.0
-uritemplate==4.1.1
-urllib3==1.26.7
-websocket-client==1.2.1
diff --git a/utils/CreateMovie.py b/utils/CreateMovie.py
deleted file mode 100644
index 0f878c4..0000000
--- a/utils/CreateMovie.py
+++ /dev/null
@@ -1,111 +0,0 @@
-from moviepy.editor import *
-import random
-import os
-
-dir_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
-
-def GetDaySuffix(day):
- if day == 1 or day == 21 or day == 31:
- return "st"
- elif day == 2 or day == 22:
- return "nd"
- elif day == 3 or day == 23:
- return "rd"
- else:
- return "th"
-
-dir_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
-music_path = os.path.join(dir_path, "Music/")
-
-def add_return_comment(comment):
- need_return = 30
- new_comment = ""
- return_added = 0
- return_added += comment.count('\n')
- for i, letter in enumerate(comment):
- if i > need_return and letter == " ":
- letter = "\n"
- need_return += 30
- return_added += 1
- new_comment += letter
- return new_comment, return_added
-
-
-class CreateMovie():
-
- @classmethod
- def CreateMP4(cls, post_data):
-
- clips = []
- for post in post_data:
- if "gif" not in post['image_path']:
- clip = ImageSequenceClip([post['image_path']], durations=[12])
- clips.append(clip)
- else:
- clip = VideoFileClip(post['image_path'])
- clip_lengthener = [clip] * 60
- clip = concatenate_videoclips(clip_lengthener)
- clip = clip.subclip(0,12)
- clips.append(clip)
-
- # After we have out clip.
- clip = concatenate_videoclips(clips)
-
- # Hack to fix getting extra frame errors??
- clip = clip.subclip(0,60)
-
- colors = ['yellow', 'LightGreen', 'LightSkyBlue', 'LightPink4', 'SkyBlue2', 'MintCream','LimeGreen', 'WhiteSmoke', 'HotPink4']
- colors = colors + ['PeachPuff3', 'OrangeRed3', 'silver']
- random.shuffle(colors)
- text_clips = []
- notification_sounds = []
- for i, post in enumerate(post_data):
- return_comment, return_count = add_return_comment(post['Best_comment'])
- txt = TextClip(return_comment, font='Courier',
- fontsize=38, color=colors.pop(), bg_color='black')
- txt = txt.on_color(col_opacity=.3)
- txt = txt.set_position((5,500))
- txt = txt.set_start((0, 3 + (i * 12))) # (min, s)
- txt = txt.set_duration(7)
- txt = txt.crossfadein(0.5)
- txt = txt.crossfadeout(0.5)
- text_clips.append(txt)
- return_comment, _ = add_return_comment(post['best_reply'])
- txt = TextClip(return_comment, font='Courier',
- fontsize=38, color=colors.pop(), bg_color='black')
- txt = txt.on_color(col_opacity=.3)
- txt = txt.set_position((15,585 + (return_count * 50)))
- txt = txt.set_start((0, 5 + (i * 12))) # (min, s)
- txt = txt.set_duration(7)
- txt = txt.crossfadein(0.5)
- txt = txt.crossfadeout(0.5)
- text_clips.append(txt)
- notification = AudioFileClip(os.path.join(music_path, f"notification.mp3"))
- notification = notification.set_start((0, 3 + (i * 12)))
- notification_sounds.append(notification)
- notification = AudioFileClip(os.path.join(music_path, f"notification.mp3"))
- notification = notification.set_start((0, 5 + (i * 12)))
- notification_sounds.append(notification)
-
- music_file = os.path.join(music_path, f"music{random.randint(0,4)}.mp3")
- music = AudioFileClip(music_file)
- music = music.set_start((0,0))
- music = music.volumex(.4)
- music = music.set_duration(59)
-
- new_audioclip = CompositeAudioClip([music]+notification_sounds)
- clip.write_videofile(f"video_clips.mp4", fps = 24)
-
- clip = VideoFileClip("video_clips.mp4",audio=False)
- clip = CompositeVideoClip([clip] + text_clips)
- clip.audio = new_audioclip
- clip.write_videofile("video.mp4", fps = 24)
-
-
- if os.path.exists(os.path.join(dir_path, "video_clips.mp4")):
- os.remove(os.path.join(dir_path, "video_clips.mp4"))
- else:
- print(os.path.join(dir_path, "video_clips.mp4"))
-
-if __name__ == '__main__':
- print(TextClip.list('color'))
\ No newline at end of file
diff --git a/utils/RedditBot.py b/utils/RedditBot.py
deleted file mode 100644
index 3821fd9..0000000
--- a/utils/RedditBot.py
+++ /dev/null
@@ -1,128 +0,0 @@
-from datetime import date
-import os
-import praw
-from dotenv import load_dotenv
-import requests
-import json
-from utils.Scalegif import scale_gif
-
-load_dotenv()
-
-
-class RedditBot():
-
- def __init__(self):
- self.reddit = praw.Reddit(
- client_id=os.getenv('client_id'),
- client_secret=os.getenv('client_secret'),
- user_agent=os.getenv('user_agent'),
- )
-
- dir_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
- self.data_path = os.path.join(dir_path, "data/")
- self.post_data = []
- self.already_posted = []
-
- # Check for a posted_already.json file
- self.posted_already_path = os.path.join(
- self.data_path, "posted_already.json")
- if os.path.isfile(self.posted_already_path):
- print("Loading posted_already.json from data folder.")
- with open(self.posted_already_path, "r") as file:
- self.already_posted = json.load(file)
-
- def get_posts(self, sub="memes"):
- self.post_data = []
- subreddit = self.reddit.subreddit(sub)
- posts = []
- for submission in subreddit.top("day", limit=100):
- if submission.stickied:
- print("Mod Post")
- else:
- posts.append(submission)
-
- return posts
-
- def create_data_folder(self):
- today = date.today()
- dt_string = today.strftime("%m%d%Y")
- data_folder_path = os.path.join(self.data_path, f"{dt_string}/")
- check_folder = os.path.isdir(data_folder_path)
- # If folder doesn't exist, then create it.
- if not check_folder:
- os.makedirs(data_folder_path)
-
- def save_image(self, submission, scale=(720, 1280)):
- if "jpg" in submission.url.lower() or "png" in submission.url.lower() or "gif" in submission.url.lower() and "gifv" not in submission.url.lower():
- # try:
-
- # Get all images to ignore
- dt_string = date.today().strftime("%m%d%Y")
- data_folder_path = os.path.join(self.data_path, f"{dt_string}/")
- CHECK_FOLDER = os.path.isdir(data_folder_path)
- if CHECK_FOLDER and len(self.post_data) < 5 and not submission.over_18 and submission.id not in self.already_posted:
- image_path = f"{data_folder_path}Post-{submission.id}{submission.url.lower()[-4:]}"
-
- # Get the image and write the path
- reqest = requests.get(submission.url.lower())
- with open(image_path, 'wb') as f:
- f.write(reqest.content)
-
- # Could do transforms on images like resize!
- #image = cv2.resize(image,(720,1280))
- scale_gif(image_path, scale)
-
- #cv2.imwrite(f"{image_path}", image)
- submission.comment_sort = 'best'
-
- # Get best comment.
- best_comment = None
- best_comment_2 = None
-
- for top_level_comment in submission.comments:
- # Here you can fetch data off the comment.
- # For the sake of example, we're just printing the comment body.
- if len(top_level_comment.body) <= 140 and "http" not in top_level_comment.body:
- if best_comment is None:
- best_comment = top_level_comment
- else:
- best_comment_2 = top_level_comment
- break
-
- best_comment.reply_sort = "top"
- best_comment.refresh()
- replies = best_comment.replies
-
- best_reply = None
- for top_level_comment in replies:
- # Here you can fetch data off the comment.
- # For the sake of example, we're just printing the comment body.
- best_reply = top_level_comment
- if len(best_reply.body) <= 140 and "http" not in best_reply.body:
- break
-
- if best_reply is not None:
- best_reply = best_reply.body
- else:
- best_reply = "MIA"
- if best_comment_2 is not None:
- best_reply = best_comment_2.body
-
- data_file = {
- "image_path": image_path,
- 'id': submission.id,
- "title": submission.title,
- "score": submission.score,
- "18": submission.over_18,
- "Best_comment": best_comment.body,
- "best_reply": best_reply
- }
-
- self.post_data.append(data_file)
- self.already_posted.append(submission.id)
- with open(f"{data_folder_path}{submission.id}.json", "w") as outfile:
- json.dump(data_file, outfile)
- with open(self.posted_already_path, "w") as outfile:
- json.dump(self.already_posted, outfile)
- else:
- return None
diff --git a/utils/Scalegif.py b/utils/Scalegif.py
deleted file mode 100644
index cd5fd77..0000000
--- a/utils/Scalegif.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from PIL import Image
-
-def scale_gif(path, scale, new_path=None):
- gif = Image.open(path)
- if not new_path:
- new_path = path
- if path[-3:] == "gif":
- old_gif_information = {
- 'loop': bool(gif.info.get('loop', 1)),
- 'duration': gif.info.get('duration', 40),
- 'background': gif.info.get('background', 223),
- 'extension': gif.info.get('extension', (b'NETSCAPE2.0')),
- 'transparency': gif.info.get('transparency', 223)
- }
- new_frames = get_new_frames(gif, scale)
- save_new_gif(new_frames, old_gif_information, new_path)
- else:
- gif = gif.resize(scale)
- gif.save(path)
-
-
-def get_new_frames(gif, scale):
- new_frames = []
- actual_frames = gif.n_frames
- for frame in range(actual_frames):
- gif.seek(frame)
- new_frame = Image.new('RGBA', gif.size)
- new_frame.paste(gif)
- new_frame = new_frame.resize(scale, Image.ANTIALIAS)
- new_frames.append(new_frame)
- return new_frames
-
-def save_new_gif(new_frames, old_gif_information, new_path):
- new_frames[0].save(new_path,
- save_all = True,
- append_images = new_frames[1:],
- duration = old_gif_information['duration'],
- loop = old_gif_information['loop'],
- background = old_gif_information['background'],
- extension = old_gif_information['extension'] ,
- transparency = old_gif_information['transparency'])
-
-
-if __name__ == "__main__":
- scale_gif(f"Post-qtehpj.gif", (720,1280),"test.gif")
\ No newline at end of file
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..eb89ca3
--- /dev/null
+++ b/utils/__init__.py
@@ -0,0 +1,62 @@
+import os
+from pprint import pprint
+
+from environs import Env
+
+from RedditDownloader import RedditBot
+
+
+class Scales:
+ """Some known format are already defined here. You can use them by importing Scales from utils.
+
+ """
+ Default = None
+
+ # youtube format
+ YoutubeShortsFullscreen = (1080, 1920)
+ YoutubeShortsSquare = (1080, 1080)
+ YoutubeVideo = (1920, 1080)
+
+ # tiktok format
+ TikTok = YoutubeShortsFullscreen
+
+ # instagram format
+ InstagramPhotoSquare = YoutubeShortsSquare
+ InstagramPhotoLandscape = (1080, 608)
+ InstagramPhotoPortrait = (1080, 1350)
+ InstagramStories = YoutubeShortsFullscreen
+ InstagramReels = InstagramStories
+ InstagramIGTVCoverPhoto = (420, 654)
+ InstagramVideoSquare = InstagramPhotoSquare
+ InstagramVideoLandscape = (1080, 608)
+ InstagramVideoPortrait = InstagramPhotoPortrait
+
+ # snapchat format
+ Snapchat = YoutubeShortsFullscreen
+
+ @staticmethod
+ def show_attributes():
+ pprint([i for i in dir(Scales) if not i.startswith("__") and i != "show_attributes"])
+
+
+class SocialMedias:
+ YouTube = 0
+ TikTok = 1
+ Instagram = 2
+ Snapchat = 3
+
+ @staticmethod
+ def show_attributes():
+ pprint([i for i in dir(SocialMedias) if not i.startswith("__") and i != "show_attributes"])
+
+
+def get_credentials():
+ env = Env()
+ env.read_env()
+ return env
+
+
+def initialize(base_path: str = os.getcwd()):
+ RedditBot(get_credentials(), base_path)
+ print("Application initialized !")
+ exit()
diff --git a/utils/upload_video.py b/utils/upload_video.py
deleted file mode 100644
index 624254e..0000000
--- a/utils/upload_video.py
+++ /dev/null
@@ -1,151 +0,0 @@
-import httplib2
-import os
-import random
-import sys
-import time
-
-from apiclient.discovery import build
-from apiclient.errors import HttpError
-from apiclient.http import MediaFileUpload
-from oauth2client.client import flow_from_clientsecrets
-from oauth2client.file import Storage
-from oauth2client.tools import argparser, run_flow
-
-
-# Explicitly tell the underlying HTTP transport library not to retry, since
-# we are handling retry logic ourselves.
-httplib2.RETRIES = 1
-
-# Maximum number of times to retry before giving up.
-MAX_RETRIES = 10
-
-# Always retry when these exceptions are raised.
-RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError)
-
-# Always retry when an apiclient.errors.HttpError with one of these status
-# codes is raised.
-RETRIABLE_STATUS_CODES = [500, 502, 503, 504]
-CLIENT_SECRETS_FILE = "client_secrets.json"
-
-# This OAuth 2.0 access scope allows an application to upload files to the
-# authenticated user's YouTube channel, but doesn't allow other types of access.
-YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload"
-YOUTUBE_API_SERVICE_NAME = "youtube"
-YOUTUBE_API_VERSION = "v3"
-
-# This variable defines a message to display if the CLIENT_SECRETS_FILE is
-# missing.
-MISSING_CLIENT_SECRETS_MESSAGE = """
-WARNING: Please configure OAuth 2.0
-
-To make this sample run you will need to populate the client_secrets.json file
-found at:
-
- %s
-
-with information from the API Console
-https://console.developers.google.com/
-
-For more information about the client_secrets.json file format, please visit:
-https://developers.google.com/api-client-library/python/guide/aaa_client_secrets
-""" % os.path.abspath(os.path.join(os.path.dirname(__file__),
- CLIENT_SECRETS_FILE))
-
-VALID_PRIVACY_STATUSES = ("public", "private", "unlisted")
-
-
-def get_authenticated_service(args):
- flow = flow_from_clientsecrets(CLIENT_SECRETS_FILE,
- scope=YOUTUBE_UPLOAD_SCOPE,
- message=MISSING_CLIENT_SECRETS_MESSAGE)
-
- storage = Storage("%s-oauth2.json" % sys.argv[0])
- credentials = storage.get()
-
- if credentials is None or credentials.invalid:
- credentials = run_flow(flow, storage, args)
-
- return build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION,
- http=credentials.authorize(httplib2.Http()))
-
-def initialize_upload(youtube, options):
- tags = None
-# if options.keywords:
-# tags = options.keywords.split(",")
-
- body=dict(
- snippet=dict(
- title=options['title'],
- description=options['description'],
- tags=tags,
- #categoryId=options['category']
- ),
- status=dict(
- privacyStatus=options['privacyStatus']
- )
- )
-
- # Call the API's videos.insert method to create and upload the video.
- insert_request = youtube.videos().insert(
- part=",".join(body.keys()),
- body=body,
- media_body=MediaFileUpload(options['file'], chunksize=-1, resumable=True)
- )
-
- resumable_upload(insert_request)
-
-# This method implements an exponential backoff strategy to resume a
-# failed upload.
-def resumable_upload(insert_request):
- response = None
- error = None
- retry = 0
- while response is None:
- try:
- print("Uploading file...")
- status, response = insert_request.next_chunk()
- if response is not None:
- if 'id' in response:
- print("Video id '%s' was successfully uploaded." % response['id'])
- else:
- exit("The upload failed with an unexpected response: %s" % response)
- except HttpError as e:
- if e.resp.status in RETRIABLE_STATUS_CODES:
- error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status,
- e.content)
- else:
- raise
- except RETRIABLE_EXCEPTIONS as e:
- error = "A retriable error occurred: %s" % e
-
- if error is not None:
- print(error)
- retry += 1
- if retry > MAX_RETRIES:
- exit("No longer attempting to retry.")
-
- max_sleep = 2 ** retry
- sleep_seconds = random.random() * max_sleep
- print("Sleeping %f seconds and then retrying..." % sleep_seconds)
- time.sleep(sleep_seconds)
-
-def upload_video(video_data):
- args = argparser.parse_args()
- if not os.path.exists(video_data['file']):
- exit("Please specify a valid file using the --file= parameter.")
-
- youtube = get_authenticated_service(args)
- try:
- initialize_upload(youtube, video_data)
- except HttpError as e:
- print("An HTTP error %d occurred:\n%s" % (e.resp.status, e.content))
-
-if __name__ == '__main__':
- video_data = {
- "file": "video.mp4",
- "title": "Best of memes!",
- "description": "#shorts \n Giving you the hottest memes of the day with funny comments!",
- "keywords":"meme,reddit",
- "privacyStatus":"private"
- }
- update_video(video_data)
\ No newline at end of file