feat: automate pipeline blockers — TTS fallback, 2FA auto-code, YouTube upload wiring

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/2551/head
Hong Phuc 4 weeks ago
parent e23094ae3b
commit 89b1c0d0e5

@ -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:

@ -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",

@ -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)

@ -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"]

@ -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

@ -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'] }

@ -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
Loading…
Cancel
Save