diff --git a/TTS/TikTok.py b/TTS/TikTok.py index 23d2918..1e85200 100644 --- a/TTS/TikTok.py +++ b/TTS/TikTok.py @@ -80,10 +80,15 @@ class TikTok: """TikTok Text-to-Speech Wrapper""" def __init__(self): + sessionid = ( + settings.config.get("settings", {}) + .get("tts", {}) + .get("tiktok_sessionid", "") + ) headers = { "User-Agent": "com.zhiliaoapp.musically/2022600030 (Linux; U; Android 7.1.2; es_ES; SM-G988N; " "Build/NRD90M;tt-ok/3.12.13.1)", - "Cookie": f"sessionid={settings.config['settings']['tts']['tiktok_sessionid']}", + "Cookie": f"sessionid={sessionid}", } self.URI_BASE = "https://api16-normal-c-useast1a.tiktokv.com/media/api/text/speech/invoke/" @@ -94,33 +99,35 @@ class TikTok: self._session.headers = headers def run(self, text: str, filepath: str, random_voice: bool = False): - if random_voice: - voice = self.random_voice() - else: - # if tiktok_voice is not set in the config file, then use a random voice - voice = settings.config["settings"]["tts"].get("tiktok_voice", None) - - # get the audio from the TikTok API - data = self.get_voices(voice=voice, text=text) - - # check if there was an error in the request - status_code = data["status_code"] - if status_code != 0: - raise TikTokTTSException(status_code, data["message"]) - - # decode data from base64 to binary try: - raw_voices = data["data"]["v_str"] - except: - print( - "The TikTok TTS returned an invalid response. Please try again later, and report this bug." - ) - raise TikTokTTSException(0, "Invalid response") - decoded_voices = base64.b64decode(raw_voices) - - # write voices to specified filepath - with open(filepath, "wb") as out: - out.write(decoded_voices) + if random_voice: + voice = self.random_voice() + else: + # if tiktok_voice is not set in the config file, then use a random voice + voice = settings.config["settings"]["tts"].get("tiktok_voice", None) + + # get the audio from the TikTok API + data = self.get_voices(voice=voice, text=text) + + # check if there was an error in the request + status_code = data["status_code"] + if status_code != 0: + raise TikTokTTSException(status_code, data["message"]) + + # decode data from base64 to binary + try: + raw_voices = data["data"]["v_str"] + except (KeyError, TypeError): + raise TikTokTTSException(0, "Invalid response: missing v_str field") + decoded_voices = base64.b64decode(raw_voices) + + # write voices to specified filepath + with open(filepath, "wb") as out: + out.write(decoded_voices) + except TikTokTTSException: + raise # Re-raise TikTok-specific errors as-is + except Exception as err: + raise TikTokTTSException(0, f"Unexpected error in TikTok TTS: {err}") def get_voices(self, text: str, voice: Optional[str] = None) -> dict: """If voice is not passed, the API will try to use the most fitting voice""" @@ -136,11 +143,17 @@ class TikTok: # send request try: response = self._session.post(self.URI_BASE, params=params) - except ConnectionError: + except requests.RequestException: time.sleep(random.randrange(1, 7)) - response = self._session.post(self.URI_BASE, params=params) + try: + response = self._session.post(self.URI_BASE, params=params) + except requests.RequestException as err: + raise TikTokTTSException(0, f"Network error contacting TikTok API: {err}") - return response.json() + try: + return response.json() + except ValueError as err: + raise TikTokTTSException(0, f"Invalid JSON response from TikTok API: {err}") @staticmethod def random_voice() -> str: diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index 89e3b7a..a3756c9 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -14,6 +14,10 @@ from utils import settings from utils.console import print_step, print_substep from utils.voice import sanitize_text +# TikTok + pyttsx3 imports — used for graceful fallback when TikTok TTS fails +from TTS.TikTok import TikTokTTSException +from TTS.pyttsx import pyttsx as PyttsxModule + DEFAULT_MAX_LENGTH: int = ( 50 # Video length variable, edit this on your own risk. It should work, but it's not supported ) @@ -142,13 +146,26 @@ class TTSEngine: print("OSError") def call_tts(self, filename: str, text: str): - if settings.config["settings"]["tts"]["voice_choice"] == "googletranslate": - # GTTS does not have the argument 'random_voice' - self.tts_module.run( - text, - filepath=f"{self.path}/{filename}.mp3", + try: + if settings.config["settings"]["tts"]["voice_choice"] == "googletranslate": + # GTTS does not have the argument 'random_voice' + self.tts_module.run( + text, + filepath=f"{self.path}/{filename}.mp3", + ) + else: + self.tts_module.run( + text, + filepath=f"{self.path}/{filename}.mp3", + random_voice=settings.config["settings"]["tts"]["random_voice"], + ) + except TikTokTTSException as err: + print_substep( + f"TikTok TTS failed ({err}). Falling back to pyttsx3 for this segment.", + "bold yellow", ) - else: + settings.config["settings"]["tts"]["voice_choice"] = "pyttsx" + self.tts_module = PyttsxModule() self.tts_module.run( text, filepath=f"{self.path}/{filename}.mp3", diff --git a/main.py b/main.py index 01c2dad..3fcdcae 100755 --- a/main.py +++ b/main.py @@ -19,8 +19,9 @@ from video_creation.background import ( download_background_video, get_background_config, ) -from video_creation.final_video import make_final_video +from video_creation.final_video import make_final_video, name_normalize from video_creation.voices import save_text_to_mp3 +from video_creation.youtube_uploader import upload_to_youtube # Guard prawcore import — only available when Reddit is used try: @@ -76,6 +77,32 @@ def main(POST_ID=None) -> None: chop_background(bg_config, length, reddit_object) make_final_video(number_of_comments, length, reddit_object, bg_config) + # -- YouTube upload (if enabled in config) --------------------------- + youtube_config = settings.config.get("youtube", {}) + if youtube_config.get("enabled", False): + # Compute the video path using the same logic as final_video.py + title_raw = reddit_object.get("thread_title", "video") + filename = f"{name_normalize(title_raw)[:251]}" + platform = settings.config["settings"].get("platform", "reddit") + if platform == "reddit": + subreddit = ( + settings.config.get("reddit", {}) + .get("thread", {}) + .get("subreddit", "unknown") + ) + else: + subreddit = reddit_object.get("thread_category", platform) + video_path = f"results/{subreddit}/{filename}.mp4" + + youtube_url = upload_to_youtube( + video_path, title_raw, settings.config + ) + if youtube_url: + print_substep(f"YouTube URL: {youtube_url}", "bold green") + else: + print_substep("YouTube upload skipped or failed.", "yellow") + # --------------------------------------------------------------------- + def run_many(times) -> None: for x in range(1, times + 1): @@ -113,10 +140,12 @@ if __name__ == "__main__": or settings.config["settings"]["tts"]["tiktok_sessionid"] == "" ) and config["settings"]["tts"]["voice_choice"] == "tiktok": print_substep( - "TikTok voice requires a sessionid! Check our documentation on how to obtain one.", - "bold red", + "TikTok voice requires a sessionid! " + "Falling back to pyttsx3 (offline TTS, no API key needed). " + "Set a valid tiktok_sessionid in your config.toml to use TikTok voices.", + "bold yellow", ) - sys.exit() + config["settings"]["tts"]["voice_choice"] = "pyttsx" try: platform = config["settings"].get("platform", "reddit") post_id_str = _get_platform_post_id(config, platform) diff --git a/reddit/subreddit.py b/reddit/subreddit.py index f54f13e..aa34f84 100644 --- a/reddit/subreddit.py +++ b/reddit/subreddit.py @@ -22,11 +22,23 @@ def get_subreddit_threads(POST_ID: str): content = {} if settings.config["reddit"]["creds"]["2fa"]: - print("\nEnter your two-factor authentication code from your authenticator app.\n") - code = input("> ") - print() - pw = settings.config["reddit"]["creds"]["password"] - passkey = f"{pw}:{code}" + twofa_secret = settings.config["reddit"]["creds"].get("2fa_secret", "") + if twofa_secret: + import pyotp + + totp = pyotp.TOTP(twofa_secret) + code = totp.now() + pw = settings.config["reddit"]["creds"]["password"] + passkey = f"{pw}:{code}" + else: + print( + "\nEnter your two-factor authentication code from your authenticator app.\n" + "(To skip this prompt in the future, set 2fa_secret in config.toml)\n" + ) + code = input("> ") + print() + pw = settings.config["reddit"]["creds"]["password"] + passkey = f"{pw}:{code}" else: passkey = settings.config["reddit"]["creds"]["password"] username = settings.config["reddit"]["creds"]["username"] diff --git a/requirements.txt b/requirements.txt index 606cf11..6e115f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ botocore==1.42.94 gTTS==2.5.4 moviepy==2.2.1 playwright==1.58.0 +pyotp==2.9.0 praw==7.8.1 requests==2.32.5 rich==15.0.0 @@ -19,3 +20,5 @@ transformers==4.57.6 ffmpeg-python==0.2.0 elevenlabs==2.44.0 yt-dlp==2025.10.14 +google-auth-oauthlib==1.2.1 +google-api-python-client==2.159.0 diff --git a/utils/.config.template.toml b/utils/.config.template.toml index e78dcb3..d3db198 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -4,6 +4,7 @@ client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "The SEC username = { optional = false, nmin = 3, nmax = 20, explanation = "The username of your reddit account", example = "JasonLovesDoggo", regex = "^[-_0-9a-zA-Z]+$", oob_error = "A username HAS to be between 3 and 20 characters" } password = { optional = false, nmin = 8, explanation = "The password of your reddit account", example = "fFAGRNJru1FTz70BzhT3Zg", oob_error = "Password too short" } 2fa = { optional = true, type = "bool", options = [true, false, ], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true } +2fa_secret = { optional = true, default = "", explanation = "TOTP shared secret (base32). If provided, 2FA codes are generated automatically instead of prompting interactively.", example = "JBSWY3DPEHPK3PXP" } [reddit.thread] @@ -33,6 +34,13 @@ min_reply_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type min_replies = { default = 5, optional = false, nmin = 1, type = "int", explanation = "Minimum number of replies for a post to be eligible", example = 5, oob_error = "Minimum replies should be at least 1" } blocked_words = { optional = true, default = "", type = "str", explanation = "Comma-separated list of blocked words/phrases. Posts and replies containing any of these will be skipped.", example = "nsfw, spoiler, politics" } +[youtube] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Enable automatic YouTube upload after video creation" } +privacy = { optional = true, default = "public", options = ["public", "private", "unlisted"], explanation = "YouTube video privacy status" } +category = { optional = true, default = "22", explanation = "YouTube category ID (22 = People & Blogs)" } +tags = { optional = true, default = "shorts, reddit", explanation = "Comma-separated tags for the video" } +client_secret_path = { optional = true, default = "", explanation = "Path to youtube_client_secret.json for OAuth2 authentication" } + [settings] platform = { optional = false, default = "reddit", options = ["reddit", "threads"], explanation = "Which social media platform to pull content from." } post_lang = { default = "", optional = true, explanation = "The language you would like to translate to. Applies to all platforms.", example = "es-cr", options = ['','af', 'ak', 'am', 'ar', 'as', 'ay', 'az', 'be', 'bg', 'bho', 'bm', 'bn', 'bs', 'ca', 'ceb', 'ckb', 'co', 'cs', 'cy', 'da', 'de', 'doi', 'dv', 'ee', 'el', 'en', 'en-US', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gom', 'gu', 'ha', 'haw', 'hi', 'hmn', 'hr', 'ht', 'hu', 'hy', 'id', 'ig', 'ilo', 'is', 'it', 'iw', 'ja', 'jw', 'ka', 'kk', 'km', 'kn', 'ko', 'kri', 'ku', 'ky', 'la', 'lb', 'lg', 'ln', 'lo', 'lt', 'lus', 'lv', 'mai', 'mg', 'mi', 'mk', 'ml', 'mn', 'mni-Mtei', 'mr', 'ms', 'mt', 'my', 'ne', 'nl', 'no', 'nso', 'ny', 'om', 'or', 'pa', 'pl', 'ps', 'pt', 'qu', 'ro', 'ru', 'rw', 'sa', 'sd', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vi', 'xh', 'yi', 'yo', 'zh-CN', 'zh-TW', 'zu'] } diff --git a/video_creation/youtube_uploader.py b/video_creation/youtube_uploader.py new file mode 100644 index 0000000..a874a44 --- /dev/null +++ b/video_creation/youtube_uploader.py @@ -0,0 +1,180 @@ +""" +YouTube Uploader — OAuth2-authenticated upload to YouTube. + +Imports the upload logic pattern from vendor/FullyAutomatedRedditVideoMakerBot/uploaders/youtubeUpload.py +but is a standalone reimplementation that: + - Reads config from the [youtube] section of config.toml + - Lets the user point to their youtube_client_secret.json via config + - Caches OAuth2 tokens to video_creation/data/YTtoken.json + - Derives title, description, tags, privacy, category from config + - Handles missing dependencies and missing secret files gracefully +""" + +import os +import sys + +from utils.console import print_markdown, print_step, print_substep + +SCOPES = ["https://www.googleapis.com/auth/youtube.upload"] +TOKEN_FILE = os.path.join("video_creation", "data", "YTtoken.json") + + +def _get_authenticated_service(client_secret_path): + """ + Authenticate with YouTube via OAuth2. + + Returns a googleapiclient.discovery.Resource (youtube v3) or None on failure. + """ + # Lazy imports so missing dependencies don't crash the pipeline + try: + from google.oauth2.credentials import Credentials + from google_auth_oauthlib.flow import InstalledAppFlow + from googleapiclient.discovery import build + import google.auth.transport.requests + except ImportError: + print_substep( + "YouTube upload requires google-auth-oauthlib and google-api-python-client.\n" + "Install them with: pip install google-auth-oauthlib google-api-python-client", + "bold red", + ) + return None + + # Validate client secret file exists + if not client_secret_path or not os.path.isfile(client_secret_path): + print_substep( + f"YouTube client secret not found at: '{client_secret_path}'.\n" + "Set youtube.client_secret_path in config.toml to the path of your " + "youtube_client_secret.json file (downloaded from Google Cloud Console).", + "bold red", + ) + return None + + credentials = None + + # Load previously cached token if available + if os.path.isfile(TOKEN_FILE): + try: + with open(TOKEN_FILE, "r") as f: + credentials = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES) + except Exception: + credentials = None + + # Refresh expired token or start fresh OAuth flow + if not credentials or not credentials.valid: + if credentials and credentials.expired and credentials.refresh_token: + try: + credentials.refresh(google.auth.transport.requests.Request()) + except Exception: + credentials = None + + if not credentials: + try: + print_substep( + "Opening browser for YouTube OAuth2 authorization...", + "blue", + ) + flow = InstalledAppFlow.from_client_secrets_file( + client_secret_path, SCOPES + ) + credentials = flow.run_local_server(port=0) + except Exception as e: + print_substep(f"YouTube OAuth2 authentication failed: {e}", "bold red") + return None + + # Cache credentials for future runs + os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True) + with open(TOKEN_FILE, "w") as f: + f.write(credentials.to_json()) + print_substep("YouTube credentials cached to video_creation/data/YTtoken.json", "green") + + return build("youtube", "v3", credentials=credentials) + + +def upload_to_youtube(video_path, video_title, config): + """ + Upload a video to YouTube using settings from the [youtube] config section. + + The function is safe to call even when youtube is disabled — it will + return None immediately with a log message. + + Args: + video_path: Absolute or relative path to the .mp4 video file. + video_title: Display title for the YouTube video (typically the + thread title from the content object). + config: Full application configuration dict (settings.config). + + Returns: + str — YouTube URL (https://youtu.be/VIDEO_ID) on success, or + None if the upload is disabled, skipped, or failed. + """ + youtube_config = config.get("youtube", {}) + enabled = youtube_config.get("enabled", False) + + if not enabled: + print_substep( + "YouTube upload skipped (youtube.enabled = false in config.toml).", + "yellow", + ) + return None + + if not os.path.isfile(video_path): + print_substep(f"Video file not found: {video_path}", "bold red") + return None + + client_secret_path = youtube_config.get("client_secret_path", "") + + print_step("Uploading video to YouTube...") + + youtube = _get_authenticated_service(client_secret_path) + if youtube is None: + return None + + # Build upload metadata from config (with sensible defaults) + tags_str = youtube_config.get("tags", "shorts, reddit") + tags = [t.strip() for t in tags_str.split(",") if t.strip()] + privacy = youtube_config.get("privacy", "public") + category = youtube_config.get("category", "22") + + description = youtube_config.get( + "description", + f"{video_title}\n\n#shorts #short #reddit", + ) + + try: + from googleapiclient.http import MediaFileUpload + + body = { + "snippet": { + "title": video_title, + "description": description, + "tags": tags, + "categoryId": category, + }, + "status": { + "privacyStatus": privacy, + "madeForKids": False, + }, + } + + media = MediaFileUpload(video_path, chunksize=-1, resumable=True) + request = youtube.videos().insert( + part="snippet,status", + body=body, + media_body=media, + ) + + response = None + while response is None: + status, response = request.next_chunk() + if status: + print_substep( + f"Uploading... {int(status.progress() * 100)}% complete." + ) + + video_url = f"https://youtu.be/{response['id']}" + print_markdown(f"## Video uploaded successfully: {video_url}") + return video_url + + except Exception as e: + print_substep(f"YouTube upload failed: {e}", "bold red") + return None