diff --git a/.gitignore b/.gitignore index af63621..ab47253 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,2 @@ __pycache__ -lib/AutoCompiler/private -lib/AutoCompiler/temp -lib/AutoCompiler/output -lib/AutoCompiler/private.py -dat/* -__init__.pyc \ No newline at end of file +*.pyc \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..5e4cd1a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include vidtools/video.yaml +include vidtools/examples +include vidtools/lib/refreshtokengen.py +graft vidtools/dat \ No newline at end of file diff --git a/README.md b/README.md index 03013f4..da5d9ce 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ -# YouTube Automation Project +# VidTools Content Creation Library A project for automating the simple task of uploading mindless content to YouTube. This library is written to leverage different input video sources and includes resources like TikTokTools to get content. In the future, other sources of video, audio, and text-based content will be provided such as Reddit over Text To Speech (TTS) and more. +## Installation Instructions +To install this package using pip, in the root directory *vidtools/* run the command +```bash +pip install . +``` + ## ```TikTokTools Class``` Built on [TikTokApi by David Teather](https://github.com/davidteather/TikTok-Api), ```TikTokTools``` is a wrapper class to enable the user to quickly source content from TikTok without needing developer API access. @@ -54,6 +60,17 @@ print(videoInstance.author) videotools.video_downloader_from_url(downloadaddr) ``` +Another helpful method is ```update_config(updated_dict)```. This method can be used to safely update ```video.yaml``` file. + +```python +updated_dict = { + 'video': { + 'title':'123455' + } + } +vt.update_config(updated_dict) +``` + ## ```YouTubeTools Class``` A wrapper class for ```googleapiclient``` [https://developers.google.com/youtube/v3/guides/uploading_a_video](https://developers.google.com/youtube/v3/guides/uploading_a_video ), this class adds functionality to upload videos to YouTube. @@ -76,7 +93,7 @@ Please see ```__init__``` for more details. ### Examples ```python -instance = YouTubeTools() +instance = YouTubeTools(file='video.mp4') youtube = instance.get_authenticated_service() # Try to upload a file out.mp4 located in /dat @@ -105,4 +122,21 @@ author = rt.get_author() text = rt.get_selftext() print(f'{title} by {author}\n{text}') +``` + +## ```TTSTools Class``` +Text To Speech (TTS) Tools ```TTSTools``` class is a helper class and wrapper for using Google wavenet Text To Speech. More information about the google tts client libraries can be found at the link [https://cloud.google.com/docs/authentication/production](https://cloud.google.com/docs/authentication/production). + +The main functionality of this class is to synthesize lifelike voice from a text entry. + +### Examples + +```python +# Instantiate TTSHelper and save outfile to dat/audio.mp3 +tts = TTSHelper(outfile_name='audio.mp3') + +# Load in text file +with open('dat/exampletextfile.txt') as file: + # Synthesize speech + tts.synthesize_text(file) ``` \ No newline at end of file diff --git a/UploadTikTok.py b/UploadTikTok.py deleted file mode 100644 index 587eb9d..0000000 --- a/UploadTikTok.py +++ /dev/null @@ -1,31 +0,0 @@ -''' - # @ Author: Andrew Hossack - # @ Create Time: 2021-03-02 20:55:32 - # @ Description: Driver file for uploading TikTok videos. - Feel free to build off of this as an example! - ''' - -from vidtools.TikTokTools import TikTokTools -from vidtools.VideoTools import VideoTools -from vidtools.YouTubeTools import YouTubeTools - -# NOTE This driver file is NOT COMPLETE! - -if __name__ == "__main__": - api = TikTokTools(verbosity=1) - videotools = VideoTools() - - num_videos_requested = 3 # Max number of return videos - max_length_seconds = 20 # Max length of videos - videolist_parsed = api.get_video_list(num_videos_requested, max_length_seconds, buffer_len=10) - - for tiktok in videolist_parsed: - # Get new video from list - desc = tiktok['desc'] - downloadaddr = tiktok['video']['downloadAddr'] - author = tiktok['author']['nickname'] - print(f"Title: {desc} by {author}\nLink: {downloadaddr}") - - # Download Video - videotools.video_downloader_from_url(downloadaddr) - print(f'Done Downloading to {videotools._downloads_dir}\n') \ No newline at end of file diff --git a/setup.py b/setup.py index 3e19421..4cb78df 100644 --- a/setup.py +++ b/setup.py @@ -3,9 +3,12 @@ with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() +# TODO Figure out how to include /examples, /lib, /dat +# vidtools/private in the install + setuptools.setup( name="VidTools", - version="0.0.2", + version="0.0.3", author="Andrew Hossack", author_email="andrew_hossack@outlook.com", description="VidTools is a video tools python package", @@ -22,10 +25,14 @@ ], packages=setuptools.find_packages(), python_requires=">=3.6", + include_package_data=True, install_requires=[ "google-api-python-client", "TikTokApi", "oauth2client", "praw", + "google-cloud", + "google-cloud-core", + "google-cloud-texttospeech" ] ) \ No newline at end of file diff --git a/video.yaml b/video.yaml deleted file mode 100644 index c378442..0000000 --- a/video.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# -# @ Author: Andrew Hossack -# @ Create Time: 2021-03-05 21:36:15 -# @ Description: video.yaml file -# - -video: - - title: 'Video Title' - description: 'Video Description' - author: 'Author Name' - playtime_min_seconds: 600 - -metadata: - - tags: ['Tag1', 'Tag2', 'Tag3'] \ No newline at end of file diff --git a/vidtools/RedditTools.py b/vidtools/RedditTools.py index b16e1bb..2e2d0ad 100644 --- a/vidtools/RedditTools.py +++ b/vidtools/RedditTools.py @@ -12,19 +12,23 @@ from praw.models import MoreComments import sys -class RedditTools(): +class RedditTools: ''' Wrapper class for praw https://praw.readthedocs.io/en/latest/ ''' - def __init__(self): + def __init__(self, secrets_filepath): ''' - self.prawclient (praw): PRAW Client instance - self.submission (post): PRAW post object, must call - set_url(url) to use other methods + args: + secrets_filepath (str): + Absolute path to secrets file + + callables: + self.prawclient (praw): PRAW Client instance + self.submission (post): PRAW post object, must call + set_url(url) to use other methods ''' - self._secrets_dir = Path( - f'{Path(os.path.join(os.path.dirname(__file__)))}/private/reddit_client_secrets.json') + self._secrets_dir = Path(secrets_filepath) with open(self._secrets_dir) as redditsecrets: self._secrets = json.load(redditsecrets) self.prawclient = praw.Reddit(client_id=self._secrets['web']['client_id'], diff --git a/vidtools/TTSTools.py b/vidtools/TTSTools.py new file mode 100644 index 0000000..6f50440 --- /dev/null +++ b/vidtools/TTSTools.py @@ -0,0 +1,63 @@ +''' + # @ Author: Andrew Hossack + # @ Create Time: 2021-03-07 18:31:20 + # @ Description: Text To Speech class + ''' + +from google.cloud import texttospeech +import os +from pathlib import Path + +class TTSHelper: + ''' + Text to speech helper class + https://googleapis.dev/python/texttospeech/latest/index.html + ''' + def __init__(self, secrets_filepath, outfile_name='audio.mp3', **kwargs): + ''' + args: + secrets_filepath (str): + Absolute path to secrets file json + kwargs: + outfile_name (str): name of audio output file + Defaults to audio.mp3 + ''' + self._text = None + self._output_directory = Path(os.path.join(os.path.dirname(__file__))).joinpath('dat') + self._secrets_filepath = Path(secrets_filepath) + self._outfile_name = outfile_name + self._client = texttospeech.TextToSpeechClient() + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = self._secrets_filepath + + def synthesize_speech(self, text, language_code="en-US", ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL): + """Synthesizes speech from the input string of text or ssml. + Note: ssml must be well-formed according to: + https://www.w3.org/TR/speech-synthesis/ + """ + self._text = text + + # Set the text input to be synthesized + synthesis_input = texttospeech.SynthesisInput(text=self._text) + + # Build the voice request, select the language code ("en-US") and the ssml + # voice gender ("neutral") + voice = texttospeech.VoiceSelectionParams( + language_code=language_code, ssml_gender=ssml_gender + ) + + # Select the type of audio file you want returned + audio_config = texttospeech.AudioConfig( + audio_encoding=texttospeech.AudioEncoding.MP3 + ) + + # Perform the text-to-speech request on the text input with the selected + # voice parameters and audio file type + response = self._client.synthesize_speech( + input=synthesis_input, voice=voice, audio_config=audio_config + ) + + # The response's audio_content is binary. + with open(f'{self._output_directory}/{self._outfile_name}', "wb") as out: + # Write the response to the output file. + out.write(response.audio_content) + print('Audio content written to file "output.mp3"') diff --git a/vidtools/TikTokTools.py b/vidtools/TikTokTools.py index 08aaa95..1a7b636 100644 --- a/vidtools/TikTokTools.py +++ b/vidtools/TikTokTools.py @@ -7,7 +7,7 @@ from TikTokApi import TikTokApi from queue import Queue -class TikTokTools(): +class TikTokTools: ''' TikTok Tools wrapper for TikTokApi https://github.com/davidteather/TikTok-Api @@ -18,18 +18,18 @@ def __init__(self, verbosity=0, **kwargs): 1 = print statements 2 = extra verbose ''' - self.api = TikTokApi(**kwargs) + self._api = TikTokApi(**kwargs) self._videos = Queue() # Implemented Queue for fun - self.requested_length = 0 + self._requested_length = 0 self._verbosity = verbosity def _check_video_shorter_than(self, video_entry): ''' Returns boolean if video is less than given length ''' - res = video_entry['video']['duration'] < self.requested_length + res = video_entry['video']['duration'] < self._requested_length if self._verbosity: - print(f"Video shorter than {self.requested_length}: {res}") + print(f"Video shorter than {self._requested_length}: {res}") return res def _check_video_not_in_list(self, video): @@ -51,20 +51,30 @@ def _check_video_not_in_list(self, video): print(f'not_in_list {not_in_list}') return not_in_list - def get_video_list(self, num_videos_requested, length_seconds, buffer_len=30): + def get_video_list(self, num_videos_requested, max_length_seconds, buffer_len=30): ''' - buffer_length (int): number of unparsed videos to initially download - - Return video list - videos (list): list of dictionary tiktok objects + args: + num_videos_requested (int): + Number of videos to try to return based on length parameter + max_length_seconds (int): + Filter video list by maximum length. Will try to return a list + of videos no longer than length_seconds + kwargs: + buffer_length (int): + number of unparsed videos to initially download + This is a hotfix to solve getting unique tiktoks + + Returns: + videos (list): + list of dictionary tiktok objects ''' # TODO BUG every time a new video is requested, it always retrieves # the first video in a list somewhere that is always the same. Need - videolist_raw = self.api.trending(buffer_len, custom_verifyFp="") + videolist_raw = self._api.trending(buffer_len, custom_verifyFp="") # to retrieve unique video. # HOTFIX: Retrieve a list of 20, 30, 50 videos and parse through each # of those as the results. - self.requested_length = length_seconds + self._requested_length = max_length_seconds for video in videolist_raw: # TODO eventually implement where new UNIQUE video is retrieved until list # is full @@ -94,6 +104,23 @@ def _get_video_by_keyword(self): ''' raise NotImplementedError + def get_video_author(self, tiktokobject): + ''' + Get video author + ''' + return tiktokobject['desc'] + + def get_video_download_address(self, tiktokobject): + ''' + Get video download address + ''' + return tiktokobject['video']['downloadAddr'] + + def get_video_description(self, tiktokobject): + ''' + Get video description + ''' + return tiktokobject['author']['nickname'] ''' ~~~~~~~~~~~~~~~~~~~ TODO List ~~~~~~~~~~~~~~~~~~~ diff --git a/vidtools/VideoTools.py b/vidtools/VideoTools.py index c15e0a9..2e19d63 100644 --- a/vidtools/VideoTools.py +++ b/vidtools/VideoTools.py @@ -4,6 +4,7 @@ # @ Description: VideoTools Class File ''' +import collections.abc import os from pathlib import Path import pprint @@ -12,9 +13,10 @@ from time import sleep, time import yaml -class VideoTools(): +class VideoTools: ''' - VideoTools class for video postprocessing + VideoTools class for video postprocessing and configuration management. + Video config can be managed with video.yaml found in the root directory ''' def __init__(self, config_yaml='video.yaml', download_dir='dat/temp'): ''' @@ -22,10 +24,10 @@ def __init__(self, config_yaml='video.yaml', download_dir='dat/temp'): ''' self._config = {} self._download_q = Queue() - self._downloads_dir = Path(f'{Path(os.path.join(os.path.dirname(__file__))).parent}/{download_dir}') - self._config_yaml_path = Path(os.path.join(os.path.dirname(__file__))).parent.joinpath(config_yaml) + self._downloads_dir = Path(os.path.join(os.path.dirname(__file__))).joinpath(download_dir) + self._config_yaml_path = Path(os.path.join(os.path.dirname(__file__))).joinpath(config_yaml) self._cleanup_downloads_dir() - self.get_config_data_from_yaml() + self._load_config() def video_downloader_from_url(self, download_url, title='video'): ''' @@ -40,20 +42,44 @@ def video_downloader_from_url(self, download_url, title='video'): with open(f'{self._downloads_dir}/{title}.mp4', 'wb') as file: file.write(req.content) + def update_config(self, updated_dict): + ''' + Update config file + args: + updated_dict (dict): + Dictionary following video.yaml structure + Example: + updated_dict = { + 'video': { + 'title':'Test Title Goes Here' + } + } + update_config(updated_dict) + ''' + self._update_config_recursive(self._config, updated_dict) + + def _update_config_recursive(self, d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = self._update_config_recursive(d.get(k, {}), v) + else: + d[k] = v + with open(self._config_yaml_path, "w") as f: + yaml.dump(d, f) + return d + def _cleanup_downloads_dir(self): ''' Remove all files from _downloads_dir ''' os.system(f'rm -rf {self._downloads_dir}/*') - def get_config_data_from_yaml(self): + def _load_config(self): ''' Get config data from self._config_yaml file and update self ''' with open(self._config_yaml_path) as config: self._config = yaml.load(config, Loader=yaml.FullLoader) - - # TODO consider not setting self.XYZ and rather just reference the yaml self.title = self._config['video']['title'] self.description = self._config['video']['description'] self.author = self._config['video']['author'] diff --git a/vidtools/YouTubeTools.py b/vidtools/YouTubeTools.py index 86876ab..b8734c2 100644 --- a/vidtools/YouTubeTools.py +++ b/vidtools/YouTubeTools.py @@ -28,7 +28,7 @@ # TODO get_authenticated_service should accept kwargs # TODO check authentication json works in /private directory loction -class YouTubeTools(): +class YouTubeTools: ''' Helper class to make uploading to YouTube easier Wrapper class for Google Api Python Client: @@ -36,31 +36,40 @@ class YouTubeTools(): Make sure to include your client_secrets.json file in vidtools directory! ''' - def __init__(self, **kwargs): + def __init__(self, secrets_filepath, **kwargs): ''' - file (str) path: This argument identifies the location of the video file that you are uploading. - The default uploadable file should be named out.mp4 - title (str): The title of the video that you are uploading. - The default value is Test title - description (str): The description of the video that you're uploading. - The default value is Test description - category (str): The category ID for the YouTube video category associated with the video. - The default value is 22, which refers to the People & Blogs category - https://developers.google.com/youtube/v3/docs/videoCategories/list - keywords (str): A comma-separated list of keywords associated with the video. - The default value is an empty string - privacyStatus (str): The privacy status of the video. - The default behavior is for an uploaded video to be publicly visible (public). - When uploading test videos, you may want to specify a --privacyStatus argument - value to ensure that those videos are private or unlisted. - Valid values are public, private, and unlisted - videoDir (str): Directory of video to upload - The default directory will be /dat unless specified - - Videos will be stored in self._video_directory directory as out.mp4 file + args: + secrets_filepath (str): + Absolute path to secrets file + + kwargs: + file (str): + Video file to look for to upload. Defaults to video.mp4 + This argument identifies the location of the video file that you are uploading. + title (str): + The title of the video that you are uploading. + The default value is Test title + description (str): + The description of the video that you're uploading. + The default value is Test description + category (str): + The category ID for the YouTube video category associated with the video. + The default value is 22, which refers to the People & Blogs category + https://developers.google.com/youtube/v3/docs/videoCategories/list + keywords (str): + A comma-separated list of keywords associated with the video. + The default value is an empty string + privacyStatus (str): + The privacy status of the video. + The default behavior is for an uploaded video to be publicly visible (public). + When uploading test videos, you may want to specify a --privacyStatus argument + value to ensure that those videos are private or unlisted. + Valid values are public, private, and unlisted + video_dir (str): + Directory of video to upload + The default directory will be /dat unless specified ''' - video_dir = kwargs.get('videoDir', 'dat') - self._video_directory = Path(f'{Path(os.path.join(os.path.dirname(__file__))).parent}/{video_dir}') + self._video_directory = Path(os.path.join(os.path.dirname(__file__))).joinpath('dat') # Explicitly tell the underlying HTTP transport library not to retry, since # we are handling retry logic ourselves. @@ -90,7 +99,7 @@ def __init__(self, **kwargs): # https://developers.google.com/youtube/v3/guides/authentication # For more information about the client_secrets.json file format, see: # https://developers.google.com/api-client-library/python/guide/aaa_client_secrets - self.CLIENT_SECRETS_FILE = "/private/youtube_client_secrets.json" + self.CLIENT_SECRETS_FILE = Path(secrets_filepath) # 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. @@ -119,12 +128,13 @@ def __init__(self, **kwargs): self._args = None self.set_args(**kwargs) - def set_args(self, videoname='out.mp4', **kwargs): + def set_args(self, **kwargs): ''' videoname (str): name of video file to be uploaded. - The default value will be out.mp4 + The default value will be video.mp4 ''' - filearg = kwargs.get('file', f'{self._video_directory}/{videoname}') + filearg = kwargs.get('file', self._video_directory.joinpath('video.mp4')) + self._filename = filearg titlearg = kwargs.get('title', "Test Title") descriptionarg = kwargs.get('description', "Test Description") categoryarg = kwargs.get('category', "22") @@ -142,8 +152,6 @@ def set_args(self, videoname='out.mp4', **kwargs): argparser.add_argument("--privacyStatus", choices=self.VALID_PRIVACY_STATUSES, default=privacystatusarg, help="Video privacy status.") self.args = argparser.parse_args() - if not os.path.exists(self.args.file): - exit(f"Check file {self._video_directory}/out.mp4 exists") def get_authenticated_service(self): flow = flow_from_clientsecrets(self.CLIENT_SECRETS_FILE, @@ -160,6 +168,8 @@ def get_authenticated_service(self): http=credentials.authorize(httplib2.Http())) def initialize_upload(self, youtube): + if not os.path.exists(self._filename): + exit(f"Check file {self._filename} exists") tags = None if self.args.keywords: tags = self.args.keywords.split(",") @@ -199,6 +209,8 @@ def initialize_upload(self, youtube): # This method implements an exponential backoff strategy to resume a # failed upload. def resumable_upload(self, insert_request): + if not os.path.exists(self._filename): + exit(f"Check file {self._filename} exists") response = None error = None retry = 0 diff --git a/vidtools/__init__.py b/vidtools/__init__.py index e69de29..923b0b3 100644 --- a/vidtools/__init__.py +++ b/vidtools/__init__.py @@ -0,0 +1,7 @@ +# TODO figure out how to use > import VideoTools +# rather than how it is below + +from vidtools.TikTokTools import TikTokTools +from vidtools.TTSTools import TTSHelper +from vidtools.RedditTools import RedditTools +from vidtools.VideoTools import VideoTools \ No newline at end of file diff --git a/vidtools/dat/DELETE b/vidtools/dat/DELETE new file mode 100644 index 0000000..075b03e --- /dev/null +++ b/vidtools/dat/DELETE @@ -0,0 +1,3 @@ +This file may be deleted at any time + +It was included to import /dat directory \ No newline at end of file diff --git a/vidtools/examples/RedditTTS.py b/vidtools/examples/RedditTTS.py new file mode 100644 index 0000000..58eb235 --- /dev/null +++ b/vidtools/examples/RedditTTS.py @@ -0,0 +1,33 @@ +''' + # @ Author: Andrew Hossack + # @ Create Time: 2021-03-07 20:27:39 + # @ Description: Reddit to TTS example + ''' + +from vidtools.RedditTools import RedditTools +from vidtools.TTSTools import TTSHelper +import os + +''' +Download a reddit post and convert to audio file for processing +''' + +if __name__ == '__main__': + + rt = RedditTools('/Users/andrew/TikTok/vidtools/private/reddit_client_secrets.json') + tts = TTSHelper('/Users/andrew/TikTok/vidtools/private/google_tts_secrets.json') + + # Get a post URL. Reference PRAW for other automated methods + url = 'https://www.reddit.com/r/redditdev/comments/hasnnc/where_do_i_find_the_reddit_client_id_and_secret/' + rt.set_url(url) + + # Get info from post + title = rt.get_title() + author = rt.get_author() + text = rt.get_selftext() + + # Format reddit post text + body = f'{title} by {author}. {text}' + + # Download body to /dat/audio.mp3 + tts.synthesize_speech(body) \ No newline at end of file diff --git a/vidtools/examples/UploadTikTok.py b/vidtools/examples/UploadTikTok.py new file mode 100644 index 0000000..61299a8 --- /dev/null +++ b/vidtools/examples/UploadTikTok.py @@ -0,0 +1,35 @@ +''' + # @ Author: Andrew Hossack + # @ Create Time: 2021-03-02 20:55:32 + # @ Description: Driver file for uploading TikTok videos. + Feel free to build off of this as an example! + ''' + +from vidtools.TikTokTools import TikTokTools +from vidtools.VideoTools import VideoTools +from vidtools.YouTubeTools import YouTubeTools + +# NOTE This driver file is NOT COMPLETE! + +if __name__ == "__main__": + tt = TikTokTools() + vt = VideoTools() + ytt = YouTubeTools('/Users/andrew/TikTok/vidtools/private/reddit_client_secrets.json') + + videolist_parsed = tt.get_video_list( + num_videos_requested=1, + max_length_seconds=20, + buffer_len=10 ) + + for obj in videolist_parsed: + # Get new video from list + desc = tt.get_video_description(obj) + downloadaddr = tt.get_video_download_address(obj) + author = tt.get_video_author(obj) + print(f"Title: {desc} by {author}\nLink: {downloadaddr}") + + # Download Video + vt.video_downloader_from_url(downloadaddr) + print(f'Done Downloading to {vt._downloads_dir}\n') + + \ No newline at end of file diff --git a/lib/refreshtokengen.py b/vidtools/lib/refreshtokengen.py similarity index 100% rename from lib/refreshtokengen.py rename to vidtools/lib/refreshtokengen.py diff --git a/vidtools/private/google_tts_secrets.json b/vidtools/private/google_tts_secrets.json new file mode 100644 index 0000000..e69de29 diff --git a/vidtools/video.yaml b/vidtools/video.yaml new file mode 100644 index 0000000..889ecda --- /dev/null +++ b/vidtools/video.yaml @@ -0,0 +1,10 @@ +metadata: + tags: + - Tag1 + - Tag2 + - Tag3 +video: + author: Author Name + description: Video Description + playtime_min_seconds: 600 + title: Test Title Goes Here