Implement Threads Vietnam Video Maker with auto-upload and scheduling

- Add threads/ module: Threads API client for fetching content from Meta Threads
- Add video_creation/threads_screenshot.py: Generate Threads-style images with PIL
- Add uploaders/ module: YouTube, TikTok, Facebook auto-upload support
- Add scheduler/ module: APScheduler-based automated pipeline
- Update main.py: New entry point with manual/auto/scheduled modes
- Update config template: Threads, uploaders, scheduler configuration
- Update dependencies: google-api-python-client, APScheduler
- Update references from Reddit to Threads across TTS and video modules
- Maintain backward compatibility with --reddit flag

Agent-Logs-Url: https://github.com/thaitien280401-stack/RedditVideoMakerBot/sessions/b2183a86-2887-4db0-82aa-07d9da5aa1be

Co-authored-by: thaitien280401-stack <271128961+thaitien280401-stack@users.noreply.github.com>
pull/2482/head
copilot-swe-agent[bot] 4 days ago committed by GitHub
parent 14a8f6425c
commit 00a37231b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,33 +1,134 @@
# Reddit Video Maker Bot 🎥
All done WITHOUT video editing or asset compiling. Just pure ✨programming magic✨.
Created by Lewis Menelaws & [TMRRW](https://tmrrwinc.ca)
<a target="_blank" href="https://tmrrwinc.ca">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/6053155/170528535-e274dc0b-7972-4b27-af22-637f8c370133.png">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png">
<img src="https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png" width="350">
</picture>
</a>
## Video Explainer
[![lewisthumbnail](https://user-images.githubusercontent.com/6053155/173631669-1d1b14ad-c478-4010-b57d-d79592a789f2.png)
](https://www.youtube.com/watch?v=3gjcY_00U1w)
## Motivation 🤔
These videos on TikTok, YouTube and Instagram get MILLIONS of views across all platforms and require very little effort.
The only original thing being done is the editing and gathering of all materials...
... but what if we can automate that process? 🤔
## Disclaimers 🚨
- **At the moment**, this repository won't attempt to upload this content through this bot. It will give you a file that
# 🇻🇳 Threads Video Maker Bot - Phiên Bản Việt Nam 🎥
Tạo video tự động từ nội dung **Threads (Meta)** và đăng lên **TikTok**, **YouTube**, **Facebook**.
Được phát triển dựa trên nền tảng Reddit Video Maker Bot, tối ưu hóa cho thị trường Việt Nam.
## Tính Năng Chính ✨
- 📱 **Threads Integration**: Lấy nội dung tự động từ Threads (Meta) thay vì Reddit
- 🎙️ **TTS Tiếng Việt**: Hỗ trợ đọc tiếng Việt qua Google Translate TTS, OpenAI, và nhiều engine khác
- 📤 **Auto-Upload**: Tự động đăng video lên TikTok, YouTube, Facebook
- ⏰ **Lên Lịch Tự Động**: Cron-based scheduling với múi giờ Việt Nam
- 🎬 **Video Chất Lượng**: Background gaming, nhạc nền lofi, subtitle overlay
- 🔄 **Pipeline Hoàn Chỉnh**: Từ lấy nội dung → TTS → Screenshot → Video → Upload
## Cài Đặt 🛠️
### Yêu Cầu
- Python 3.10, 3.11 hoặc 3.12
- FFmpeg
- Tài khoản Threads Developer (Meta)
### Bước 1: Clone và cài đặt
```bash
git clone https://github.com/thaitien280401-stack/RedditVideoMakerBot.git
cd RedditVideoMakerBot
pip install -r requirements.txt
```
### Bước 2: Cấu hình
Chạy lần đầu để tạo file `config.toml`:
```bash
python main.py
```
Hoặc copy từ template:
```bash
cp utils/.config.template.toml config.toml
```
### Bước 3: Điền thông tin API
Chỉnh sửa `config.toml`:
```toml
[threads.creds]
access_token = "YOUR_THREADS_ACCESS_TOKEN"
user_id = "YOUR_THREADS_USER_ID"
[settings.tts]
voice_choice = "googletranslate" # Hỗ trợ tiếng Việt tốt nhất
```
## Sử Dụng 🚀
### Chế độ Manual (Mặc định)
```bash
python main.py
```
### Chế độ Auto (Tạo + Upload)
```bash
python main.py --mode auto
```
### Chế độ Scheduled (Lên lịch tự động)
```bash
python main.py --mode scheduled
```
### Legacy Reddit Mode
```bash
python main.py --reddit
```
## Cấu Hình Upload 📤
### YouTube
```toml
[uploaders.youtube]
enabled = true
client_id = "YOUR_GOOGLE_CLIENT_ID"
client_secret = "YOUR_GOOGLE_CLIENT_SECRET"
refresh_token = "YOUR_GOOGLE_REFRESH_TOKEN"
```
### TikTok
```toml
[uploaders.tiktok]
enabled = true
client_key = "YOUR_TIKTOK_CLIENT_KEY"
client_secret = "YOUR_TIKTOK_CLIENT_SECRET"
refresh_token = "YOUR_TIKTOK_REFRESH_TOKEN"
```
### Facebook
```toml
[uploaders.facebook]
enabled = true
page_id = "YOUR_FACEBOOK_PAGE_ID"
access_token = "YOUR_FACEBOOK_PAGE_ACCESS_TOKEN"
```
## Cấu Hình Scheduler ⏰
```toml
[scheduler]
enabled = true
cron = "0 8,14,20 * * *" # Chạy lúc 8h, 14h, 20h hàng ngày
timezone = "Asia/Ho_Chi_Minh"
max_videos_per_day = 4
```
## Kiến Trúc Hệ Thống 🏗️
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Threads API │────▶│ Video Engine │────▶│ Upload Manager │
│ (Meta Graph) │ │ TTS + FFmpeg │ │ YT/TT/FB APIs │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
threads/ video_creation/ uploaders/
threads_client.py threads_screenshot.py youtube_uploader.py
voices.py tiktok_uploader.py
final_video.py facebook_uploader.py
upload_manager.py
scheduler/
pipeline.py (APScheduler)
```
you will then have to upload manually. This is for the sake of avoiding any sort of community guideline issues.
## Requirements

@ -11,9 +11,15 @@ class GTTS:
self.voices = []
def run(self, text, filepath, random_voice: bool = False):
# Support both Threads and Reddit config for language
lang = "en"
if "threads" in settings.config and "thread" in settings.config["threads"]:
lang = settings.config["threads"]["thread"].get("post_lang", "") or lang
elif "reddit" in settings.config and "thread" in settings.config["reddit"]:
lang = settings.config["reddit"]["thread"].get("post_lang", "") or lang
tts = gTTS(
text=text,
lang=settings.config["reddit"]["thread"]["post_lang"] or "en",
lang=lang,
slow=False,
)
tts.save(filepath)

@ -179,7 +179,12 @@ class TTSEngine:
def process_text(text: str, clean: bool = True):
lang = settings.config["reddit"]["thread"]["post_lang"]
# Support both Threads and Reddit config
lang = ""
if "threads" in settings.config and "thread" in settings.config["threads"]:
lang = settings.config["threads"]["thread"].get("post_lang", "")
if not lang and "reddit" in settings.config and "thread" in settings.config["reddit"]:
lang = settings.config["reddit"]["thread"].get("post_lang", "")
new_text = sanitize_text(text) if clean else text
if lang:
print_substep("Translating Text...")

@ -1,4 +1,25 @@
#!/usr/bin/env python
"""
Threads Video Maker Bot - Tạo video tự động từ Threads (Meta) cho thị trường Việt Nam.
Hỗ trợ:
- Lấy nội dung từ Threads (Meta) thay Reddit
- TTS tiếng Việt (Google Translate, OpenAI, v.v.)
- Tự động upload lên TikTok, YouTube, Facebook
- Lên lịch tạo video tự động
Modes:
- manual: Tạo video thủ công (mặc định)
- auto: Tạo video tự động upload
- scheduled: Lên lịch tạo video tự động
Usage:
python main.py # Chế độ manual
python main.py --mode auto # Tạo + upload
python main.py --mode scheduled # Lên lịch tự động
python main.py --reddit # Legacy Reddit mode
"""
import math
import sys
from os import name
@ -6,88 +27,155 @@ from pathlib import Path
from subprocess import Popen
from typing import Dict, NoReturn
from prawcore import ResponseException
from reddit.subreddit import get_subreddit_threads
from utils import settings
from utils.cleanup import cleanup
from utils.console import print_markdown, print_step, print_substep
from utils.ffmpeg_install import ffmpeg_install
from utils.id import extract_id
from utils.version import checkversion
from video_creation.background import (
chop_background,
download_background_audio,
download_background_video,
get_background_config,
)
from video_creation.final_video import make_final_video
from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts
from video_creation.voices import save_text_to_mp3
__VERSION__ = "3.4.0"
__VERSION__ = "4.0.0"
print(
"""
🇻🇳 VIETNAM EDITION
Powered by Threads (Meta)
Auto-post: TikTok | YouTube | Facebook
"""
)
print_markdown(
"### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/"
"### 🇻🇳 Threads Video Maker Bot - Phiên bản Việt Nam\n"
"Tạo video tự động từ nội dung Threads và đăng lên TikTok, YouTube, Facebook.\n"
"Tài liệu: https://github.com/thaitien280401-stack/RedditVideoMakerBot"
)
checkversion(__VERSION__)
reddit_id: str
reddit_object: Dict[str, str | list]
thread_id: str
thread_object: Dict[str, str | list]
def main(POST_ID=None) -> None:
global reddit_id, reddit_object
reddit_object = get_subreddit_threads(POST_ID)
reddit_id = extract_id(reddit_object)
print_substep(f"Thread ID is {reddit_id}", style="bold blue")
length, number_of_comments = save_text_to_mp3(reddit_object)
def main_threads(POST_ID=None) -> None:
"""Pipeline chính: Lấy nội dung từ Threads → Tạo video."""
global thread_id, thread_object
from threads.threads_client import get_threads_posts
from video_creation.background import (
chop_background,
download_background_audio,
download_background_video,
get_background_config,
)
from video_creation.final_video import make_final_video
from video_creation.threads_screenshot import get_screenshots_of_threads_posts
from video_creation.voices import save_text_to_mp3
thread_object = get_threads_posts(POST_ID)
thread_id = extract_id(thread_object)
print_substep(f"Thread ID: {thread_id}", style="bold blue")
length, number_of_comments = save_text_to_mp3(thread_object)
length = math.ceil(length)
get_screenshots_of_reddit_posts(reddit_object, number_of_comments)
get_screenshots_of_threads_posts(thread_object, number_of_comments)
bg_config = {
"video": get_background_config("video"),
"audio": get_background_config("audio"),
}
download_background_video(bg_config["video"])
download_background_audio(bg_config["audio"])
chop_background(bg_config, length, reddit_object)
make_final_video(number_of_comments, length, reddit_object, bg_config)
chop_background(bg_config, length, thread_object)
make_final_video(number_of_comments, length, thread_object, bg_config)
def main_threads_with_upload(POST_ID=None) -> None:
"""Pipeline đầy đủ: Threads → Video → Upload lên các platform."""
from scheduler.pipeline import run_pipeline
run_pipeline(POST_ID)
def run_many(times) -> None:
def main_reddit(POST_ID=None) -> None:
"""Legacy mode: Sử dụng Reddit làm nguồn nội dung."""
from reddit.subreddit import get_subreddit_threads
from video_creation.background import (
chop_background,
download_background_audio,
download_background_video,
get_background_config,
)
from video_creation.final_video import make_final_video
from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts
from video_creation.voices import save_text_to_mp3
global thread_id, thread_object
thread_object = get_subreddit_threads(POST_ID)
thread_id = extract_id(thread_object)
print_substep(f"Thread ID: {thread_id}", style="bold blue")
length, number_of_comments = save_text_to_mp3(thread_object)
length = math.ceil(length)
get_screenshots_of_reddit_posts(thread_object, number_of_comments)
bg_config = {
"video": get_background_config("video"),
"audio": get_background_config("audio"),
}
download_background_video(bg_config["video"])
download_background_audio(bg_config["audio"])
chop_background(bg_config, length, thread_object)
make_final_video(number_of_comments, length, thread_object, bg_config)
def run_many(times, use_reddit=False) -> None:
"""Chạy nhiều lần tạo video."""
main_func = main_reddit if use_reddit else main_threads
for x in range(1, times + 1):
print_step(
f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}'
)
main()
print_step(f"Đang tạo video {x}/{times}...")
main_func()
Popen("cls" if name == "nt" else "clear", shell=True).wait()
def shutdown() -> NoReturn:
if "reddit_id" in globals():
print_markdown("## Clearing temp files")
cleanup(reddit_id)
if "thread_id" in globals():
print_markdown("## Đang dọn dẹp file tạm...")
cleanup(thread_id)
print("Exiting...")
print("Thoát...")
sys.exit()
def parse_args():
"""Parse command line arguments."""
import argparse
parser = argparse.ArgumentParser(description="Threads Video Maker Bot - Vietnam Edition")
parser.add_argument(
"--mode",
choices=["manual", "auto", "scheduled"],
default="manual",
help="Chế độ chạy: manual (mặc định), auto (tạo + upload), scheduled (lên lịch)",
)
parser.add_argument(
"--reddit",
action="store_true",
help="Sử dụng Reddit thay vì Threads (legacy mode)",
)
return parser.parse_args()
if __name__ == "__main__":
if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]:
print(
"Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again."
"Ứng dụng yêu cầu Python 3.10, 3.11 hoặc 3.12. Vui lòng cài đặt phiên bản phù hợp."
)
sys.exit()
args = parse_args()
ffmpeg_install()
directory = Path().absolute()
config = settings.check_toml(
@ -95,42 +183,102 @@ if __name__ == "__main__":
)
config is False and sys.exit()
# Kiểm tra TikTok TTS session
if (
not settings.config["settings"]["tts"]["tiktok_sessionid"]
or settings.config["settings"]["tts"]["tiktok_sessionid"] == ""
not settings.config["settings"]["tts"].get("tiktok_sessionid", "")
) and config["settings"]["tts"]["voice_choice"] == "tiktok":
print_substep(
"TikTok voice requires a sessionid! Check our documentation on how to obtain one.",
"TikTok TTS cần sessionid! Xem tài liệu để biết cách lấy.",
"bold red",
)
sys.exit()
try:
if config["reddit"]["thread"]["post_id"]:
for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")):
index += 1
print_step(
f'on the {index}{("st" if index % 10 == 1 else ("nd" if index % 10 == 2 else ("rd" if index % 10 == 3 else "th")))} post of {len(config["reddit"]["thread"]["post_id"].split("+"))}'
)
main(post_id)
Popen("cls" if name == "nt" else "clear", shell=True).wait()
elif config["settings"]["times_to_run"]:
run_many(config["settings"]["times_to_run"])
if args.mode == "scheduled":
# Chế độ lên lịch tự động
print_step("🕐 Khởi động chế độ lên lịch tự động...")
from scheduler.pipeline import run_scheduled
run_scheduled()
elif args.mode == "auto":
# Chế độ tự động: tạo + upload
print_step("🚀 Khởi động chế độ tự động (tạo + upload)...")
if args.reddit:
# Legacy Reddit mode
main_reddit()
else:
thread_config = config.get("threads", {}).get("thread", {})
post_id = thread_config.get("post_id", "")
if post_id:
for index, pid in enumerate(post_id.split("+")):
index += 1
print_step(f"Đang xử lý thread {index}/{len(post_id.split('+'))}...")
main_threads_with_upload(pid)
Popen("cls" if name == "nt" else "clear", shell=True).wait()
elif config["settings"]["times_to_run"]:
for i in range(config["settings"]["times_to_run"]):
print_step(f"Đang tạo video {i + 1}/{config['settings']['times_to_run']}...")
main_threads_with_upload()
else:
main_threads_with_upload()
else:
main()
# Chế độ manual (mặc định)
if args.reddit:
# Legacy Reddit mode
from prawcore import ResponseException
try:
if config["reddit"]["thread"]["post_id"]:
for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")):
index += 1
print_step(f"Đang xử lý post {index}...")
main_reddit(post_id)
Popen("cls" if name == "nt" else "clear", shell=True).wait()
elif config["settings"]["times_to_run"]:
run_many(config["settings"]["times_to_run"], use_reddit=True)
else:
main_reddit()
except ResponseException:
print_markdown("## Thông tin đăng nhập Reddit không hợp lệ")
print_markdown("Vui lòng kiểm tra config.toml")
shutdown()
else:
# Threads mode (mặc định)
thread_config = config.get("threads", {}).get("thread", {})
post_id = thread_config.get("post_id", "")
if post_id:
for index, pid in enumerate(post_id.split("+")):
index += 1
print_step(f"Đang xử lý thread {index}/{len(post_id.split('+'))}...")
main_threads(pid)
Popen("cls" if name == "nt" else "clear", shell=True).wait()
elif config["settings"]["times_to_run"]:
run_many(config["settings"]["times_to_run"])
else:
main_threads()
except KeyboardInterrupt:
shutdown()
except ResponseException:
print_markdown("## Invalid credentials")
print_markdown("Please check your credentials in the config.toml file")
shutdown()
except Exception as err:
config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED"
config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED"
config["settings"]["tts"]["openai_api_key"] = "REDACTED"
# Redact sensitive values
try:
config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED"
config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED"
config["settings"]["tts"]["openai_api_key"] = "REDACTED"
if "threads" in config and "creds" in config["threads"]:
config["threads"]["creds"]["access_token"] = "REDACTED"
if "uploaders" in config:
for platform in config["uploaders"]:
for key in ["access_token", "client_secret", "refresh_token"]:
if key in config["uploaders"][platform]:
config["uploaders"][platform][key] = "REDACTED"
except (KeyError, TypeError):
pass
print_step(
f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n"
f"Version: {__VERSION__} \n"
f"Error: {err} \n"
f'Config: {config["settings"]}'
f"Đã xảy ra lỗi! Vui lòng thử lại hoặc báo lỗi trên GitHub.\n"
f"Phiên bản: {__VERSION__}\n"
f"Lỗi: {err}\n"
f'Config: {config.get("settings", {})}'
)
raise err

@ -1,3 +1,4 @@
# Core dependencies
boto3==1.36.8
botocore==1.36.8
gTTS==2.5.4
@ -19,3 +20,8 @@ transformers==4.52.4
ffmpeg-python==0.2.0
elevenlabs==1.57.0
yt-dlp==2025.10.22
# New dependencies for Threads Vietnam Video Maker
google-api-python-client==2.166.0
google-auth-oauthlib==1.2.1
APScheduler==3.11.0

@ -0,0 +1,223 @@
"""
Scheduler - Hệ thống lên lịch tự động tạo đăng video.
Sử dụng APScheduler để lên lịch các tác vụ tự động.
"""
import math
import os
import sys
from datetime import datetime
from os import name
from pathlib import Path
from subprocess import Popen
from typing import Optional
from utils import settings
from utils.cleanup import cleanup
from utils.console import print_markdown, print_step, print_substep
from utils.id import extract_id
def run_pipeline(post_id: Optional[str] = None) -> Optional[str]:
"""Chạy toàn bộ pipeline tạo video từ Threads.
Pipeline:
1. Lấy nội dung từ Threads
2. Tạo TTS audio
3. Tạo screenshots
4. Tải background video/audio
5. Ghép video cuối cùng
6. Upload lên các platform (nếu được cấu hình)
Args:
post_id: ID cụ thể của thread (optional).
Returns:
Đường dẫn file video đã tạo, hoặc None nếu thất bại.
"""
from threads.threads_client import get_threads_posts
from video_creation.background import (
chop_background,
download_background_audio,
download_background_video,
get_background_config,
)
from video_creation.final_video import make_final_video
from video_creation.threads_screenshot import get_screenshots_of_threads_posts
from video_creation.voices import save_text_to_mp3
print_step("🚀 Bắt đầu pipeline tạo video...")
try:
# Step 1: Lấy nội dung từ Threads
print_step("📱 Bước 1: Lấy nội dung từ Threads...")
thread_object = get_threads_posts(post_id)
thread_id = extract_id(thread_object)
print_substep(f"Thread ID: {thread_id}", style="bold blue")
# Step 2: Tạo TTS audio
print_step("🎙️ Bước 2: Tạo audio TTS...")
length, number_of_comments = save_text_to_mp3(thread_object)
length = math.ceil(length)
# Step 3: Tạo screenshots
print_step("📸 Bước 3: Tạo hình ảnh...")
get_screenshots_of_threads_posts(thread_object, number_of_comments)
# Step 4: Background
print_step("🎬 Bước 4: Xử lý background...")
bg_config = {
"video": get_background_config("video"),
"audio": get_background_config("audio"),
}
download_background_video(bg_config["video"])
download_background_audio(bg_config["audio"])
chop_background(bg_config, length, thread_object)
# Step 5: Ghép video cuối cùng
print_step("🎥 Bước 5: Tạo video cuối cùng...")
make_final_video(number_of_comments, length, thread_object, bg_config)
# Tìm file video đã tạo
subreddit = settings.config.get("threads", {}).get("thread", {}).get("channel_name", "threads")
results_dir = f"./results/{subreddit}"
video_path = None
if os.path.exists(results_dir):
files = sorted(
[f for f in os.listdir(results_dir) if f.endswith(".mp4")],
key=lambda x: os.path.getmtime(os.path.join(results_dir, x)),
reverse=True,
)
if files:
video_path = os.path.join(results_dir, files[0])
# Step 6: Upload (nếu cấu hình)
upload_config = settings.config.get("uploaders", {})
has_uploaders = any(
upload_config.get(p, {}).get("enabled", False)
for p in ["youtube", "tiktok", "facebook"]
)
if has_uploaders and video_path:
print_step("📤 Bước 6: Upload video lên các platform...")
from uploaders.upload_manager import UploadManager
manager = UploadManager()
title = thread_object.get("thread_title", "Threads Video")[:100]
description = thread_object.get("thread_post", "")[:500]
# Tìm thumbnail nếu có
thumbnail_path = None
thumb_candidate = f"./assets/temp/{thread_id}/thumbnail.png"
if os.path.exists(thumb_candidate):
thumbnail_path = thumb_candidate
results = manager.upload_to_all(
video_path=video_path,
title=title,
description=description,
thumbnail_path=thumbnail_path,
)
print_step("📊 Kết quả upload:")
for platform, url in results.items():
if url:
print_substep(f"{platform}: {url}", style="bold green")
else:
print_substep(f"{platform}: Thất bại", style="bold red")
print_step("✅ Pipeline hoàn tất!")
return video_path
except Exception as e:
print_substep(f"❌ Lỗi pipeline: {e}", style="bold red")
raise
def run_scheduled():
"""Chạy pipeline theo lịch trình đã cấu hình.
Sử dụng APScheduler để lên lịch.
"""
try:
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
except ImportError:
print_substep(
"Cần cài đặt APScheduler: pip install apscheduler",
style="bold red",
)
return
scheduler_config = settings.config.get("scheduler", {})
enabled = scheduler_config.get("enabled", False)
if not enabled:
print_substep("Scheduler chưa được kích hoạt trong config!", style="bold yellow")
return
timezone = scheduler_config.get("timezone", "Asia/Ho_Chi_Minh")
cron_expression = scheduler_config.get("cron", "0 */6 * * *") # Mặc định mỗi 6 giờ
max_videos_per_day = scheduler_config.get("max_videos_per_day", 4)
# Parse cron expression
cron_parts = cron_expression.split()
if len(cron_parts) != 5:
print_substep("Cron expression không hợp lệ! Format: minute hour day month weekday", style="bold red")
return
scheduler = BlockingScheduler(timezone=timezone)
videos_today = {"count": 0, "date": datetime.now().strftime("%Y-%m-%d")}
def scheduled_job():
"""Job được chạy theo lịch."""
current_date = datetime.now().strftime("%Y-%m-%d")
# Reset counter nếu sang ngày mới
if current_date != videos_today["date"]:
videos_today["count"] = 0
videos_today["date"] = current_date
if videos_today["count"] >= max_videos_per_day:
print_substep(
f"Đã đạt giới hạn {max_videos_per_day} video/ngày. Bỏ qua.",
style="bold yellow",
)
return
print_step(f"⏰ Scheduler: Bắt đầu tạo video lúc {datetime.now().strftime('%H:%M:%S')}...")
try:
result = run_pipeline()
if result:
videos_today["count"] += 1
print_substep(
f"Video #{videos_today['count']}/{max_videos_per_day} ngày hôm nay",
style="bold blue",
)
except Exception as e:
print_substep(f"Scheduler job thất bại: {e}", style="bold red")
trigger = CronTrigger(
minute=cron_parts[0],
hour=cron_parts[1],
day=cron_parts[2],
month=cron_parts[3],
day_of_week=cron_parts[4],
timezone=timezone,
)
scheduler.add_job(scheduled_job, trigger, id="video_pipeline", replace_existing=True)
print_step(f"📅 Scheduler đã khởi động!")
print_substep(f" Cron: {cron_expression}", style="bold blue")
print_substep(f" Timezone: {timezone}", style="bold blue")
print_substep(f" Max videos/ngày: {max_videos_per_day}", style="bold blue")
print_substep(" Nhấn Ctrl+C để dừng", style="bold yellow")
try:
scheduler.start()
except (KeyboardInterrupt, SystemExit):
scheduler.shutdown()
print_step("Scheduler đã dừng.")

@ -0,0 +1,245 @@
"""
Threads API Client - Lấy nội dung từ Meta Threads cho thị trường Việt Nam.
Meta Threads API sử dụng Graph API endpoint.
Docs: https://developers.facebook.com/docs/threads
"""
import re
from typing import Dict, List, Optional
import requests
from utils import settings
from utils.console import print_step, print_substep
from utils.videos import check_done
from utils.voice import sanitize_text
THREADS_API_BASE = "https://graph.threads.net/v1.0"
class ThreadsClient:
"""Client để tương tác với Threads API (Meta)."""
def __init__(self):
self.access_token = settings.config["threads"]["creds"]["access_token"]
self.user_id = settings.config["threads"]["creds"]["user_id"]
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {self.access_token}",
})
def _get(self, endpoint: str, params: Optional[dict] = None) -> dict:
"""Make a GET request to the Threads API."""
url = f"{THREADS_API_BASE}/{endpoint}"
if params is None:
params = {}
params["access_token"] = self.access_token
response = self.session.get(url, params=params)
response.raise_for_status()
return response.json()
def get_user_threads(self, user_id: Optional[str] = None, limit: int = 25) -> List[dict]:
"""Lấy danh sách threads của user.
Args:
user_id: Threads user ID. Mặc định user đã cấu hình.
limit: Số lượng threads tối đa cần lấy.
Returns:
Danh sách các thread objects.
"""
uid = user_id or self.user_id
data = self._get(f"{uid}/threads", params={
"fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode,is_reply,reply_audience",
"limit": limit,
})
return data.get("data", [])
def get_thread_replies(self, thread_id: str, limit: int = 50) -> List[dict]:
"""Lấy replies (comments) của một thread.
Args:
thread_id: ID của thread.
limit: Số lượng replies tối đa.
Returns:
Danh sách replies.
"""
data = self._get(f"{thread_id}/replies", params={
"fields": "id,text,timestamp,username,permalink,hide_status",
"limit": limit,
"reverse": "true",
})
return data.get("data", [])
def get_thread_by_id(self, thread_id: str) -> dict:
"""Lấy thông tin chi tiết của một thread.
Args:
thread_id: ID của thread.
Returns:
Thread object.
"""
return self._get(thread_id, params={
"fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode",
})
def search_threads_by_keyword(self, threads: List[dict], keywords: List[str]) -> List[dict]:
"""Lọc threads theo từ khóa.
Args:
threads: Danh sách threads.
keywords: Danh sách từ khóa cần tìm.
Returns:
Danh sách threads chứa từ khóa.
"""
filtered = []
for thread in threads:
text = thread.get("text", "").lower()
for keyword in keywords:
if keyword.lower() in text:
filtered.append(thread)
break
return filtered
def _contains_blocked_words(text: str) -> bool:
"""Kiểm tra xem text có chứa từ bị chặn không."""
blocked_words = settings.config["threads"]["thread"].get("blocked_words", "")
if not blocked_words:
return False
blocked_list = [w.strip().lower() for w in blocked_words.split(",") if w.strip()]
text_lower = text.lower()
return any(word in text_lower for word in blocked_list)
def get_threads_posts(POST_ID: str = None) -> dict:
"""Lấy nội dung từ Threads để tạo video.
Tương tự get_subreddit_threads() nhưng cho Threads.
Args:
POST_ID: ID cụ thể của thread. Nếu None, lấy thread mới nhất phù hợp.
Returns:
Dict chứa thread content replies.
"""
print_substep("Đang kết nối với Threads API...")
client = ThreadsClient()
content = {}
thread_config = settings.config["threads"]["thread"]
max_comment_length = int(thread_config.get("max_comment_length", 500))
min_comment_length = int(thread_config.get("min_comment_length", 1))
min_comments = int(thread_config.get("min_comments", 5))
print_step("Đang lấy nội dung từ Threads...")
if POST_ID:
# Lấy thread cụ thể theo ID
thread = client.get_thread_by_id(POST_ID)
else:
# Lấy threads mới nhất và chọn thread phù hợp
target_user = thread_config.get("target_user_id", "") or client.user_id
threads_list = client.get_user_threads(user_id=target_user, limit=25)
if not threads_list:
print_substep("Không tìm thấy threads nào!", style="bold red")
raise ValueError("No threads found")
# Lọc theo từ khóa nếu có
keywords = thread_config.get("keywords", "")
if keywords:
keyword_list = [k.strip() for k in keywords.split(",") if k.strip()]
threads_list = client.search_threads_by_keyword(threads_list, keyword_list)
# Chọn thread phù hợp (chưa tạo video, đủ replies)
thread = None
for t in threads_list:
thread_id = t.get("id", "")
# Kiểm tra xem đã tạo video cho thread này chưa
text = t.get("text", "")
if not text or _contains_blocked_words(text):
continue
# Kiểm tra số lượng replies
try:
replies = client.get_thread_replies(thread_id, limit=min_comments + 5)
if len(replies) >= min_comments:
thread = t
break
except Exception:
continue
if thread is None:
# Nếu không tìm được thread đủ comments, lấy thread đầu tiên
if threads_list:
thread = threads_list[0]
else:
print_substep("Không tìm thấy thread phù hợp!", style="bold red")
raise ValueError("No suitable thread found")
thread_id = thread.get("id", "")
thread_text = thread.get("text", "")
thread_url = thread.get("permalink", f"https://www.threads.net/post/{thread.get('shortcode', '')}")
thread_username = thread.get("username", "unknown")
print_substep(f"Video sẽ được tạo từ: {thread_text[:100]}...", style="bold green")
print_substep(f"Thread URL: {thread_url}", style="bold green")
print_substep(f"Tác giả: @{thread_username}", style="bold blue")
content["thread_url"] = thread_url
content["thread_title"] = thread_text[:200] if len(thread_text) > 200 else thread_text
content["thread_id"] = re.sub(r"[^\w\s-]", "", thread_id)
content["thread_author"] = f"@{thread_username}"
content["is_nsfw"] = False
content["thread_post"] = thread_text
content["comments"] = []
if settings.config["settings"].get("storymode", False):
# Story mode - đọc toàn bộ nội dung bài viết
content["thread_post"] = thread_text
else:
# Comment mode - lấy replies
try:
replies = client.get_thread_replies(thread_id, limit=50)
except Exception as e:
print_substep(f"Lỗi khi lấy replies: {e}", style="bold red")
replies = []
for reply in replies:
reply_text = reply.get("text", "")
reply_username = reply.get("username", "unknown")
if not reply_text:
continue
if reply.get("hide_status", "") == "HIDDEN":
continue
if _contains_blocked_words(reply_text):
continue
sanitised = sanitize_text(reply_text)
if not sanitised or sanitised.strip() == "":
continue
if len(reply_text) > max_comment_length:
continue
if len(reply_text) < min_comment_length:
continue
content["comments"].append({
"comment_body": reply_text,
"comment_url": reply.get("permalink", ""),
"comment_id": re.sub(r"[^\w\s-]", "", reply.get("id", "")),
"comment_author": f"@{reply_username}",
})
print_substep(
f"Đã lấy nội dung từ Threads thành công! ({len(content.get('comments', []))} replies)",
style="bold green",
)
return content

@ -0,0 +1,117 @@
"""
Base Uploader - Lớp sở cho tất cả uploaders.
"""
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional, List
from utils.console import print_step, print_substep
@dataclass
class VideoMetadata:
"""Metadata cho video cần upload."""
file_path: str
title: str
description: str = ""
tags: List[str] = field(default_factory=list)
hashtags: List[str] = field(default_factory=list)
thumbnail_path: Optional[str] = None
schedule_time: Optional[str] = None # ISO 8601 format
privacy: str = "public" # public, private, unlisted
category: str = "Entertainment"
language: str = "vi" # Vietnamese
class BaseUploader(ABC):
"""Lớp cơ sở cho tất cả platform uploaders."""
platform_name: str = "Unknown"
def __init__(self):
self._authenticated = False
@abstractmethod
def authenticate(self) -> bool:
"""Xác thực với platform API.
Returns:
True nếu xác thực thành công.
"""
pass
@abstractmethod
def upload(self, metadata: VideoMetadata) -> Optional[str]:
"""Upload video lên platform.
Args:
metadata: VideoMetadata chứa thông tin video.
Returns:
URL của video đã upload, hoặc None nếu thất bại.
"""
pass
def validate_video(self, metadata: VideoMetadata) -> bool:
"""Kiểm tra video có hợp lệ trước khi upload.
Args:
metadata: VideoMetadata cần kiểm tra.
Returns:
True nếu hợp lệ.
"""
if not os.path.exists(metadata.file_path):
print_substep(f"[{self.platform_name}] File không tồn tại: {metadata.file_path}", style="bold red")
return False
file_size = os.path.getsize(metadata.file_path)
if file_size == 0:
print_substep(f"[{self.platform_name}] File rỗng: {metadata.file_path}", style="bold red")
return False
if not metadata.title:
print_substep(f"[{self.platform_name}] Thiếu tiêu đề video", style="bold red")
return False
return True
def safe_upload(self, metadata: VideoMetadata, max_retries: int = 3) -> Optional[str]:
"""Upload video với retry logic.
Args:
metadata: VideoMetadata chứa thông tin video.
max_retries: Số lần thử lại tối đa.
Returns:
URL của video đã upload, hoặc None nếu thất bại.
"""
if not self.validate_video(metadata):
return None
if not self._authenticated:
print_step(f"Đang xác thực với {self.platform_name}...")
if not self.authenticate():
print_substep(f"Xác thực {self.platform_name} thất bại!", style="bold red")
return None
for attempt in range(1, max_retries + 1):
try:
print_step(f"Đang upload lên {self.platform_name} (lần {attempt}/{max_retries})...")
url = self.upload(metadata)
if url:
print_substep(
f"Upload {self.platform_name} thành công! URL: {url}",
style="bold green",
)
return url
except Exception as e:
print_substep(
f"[{self.platform_name}] Lỗi upload (lần {attempt}): {e}",
style="bold red",
)
print_substep(f"Upload {self.platform_name} thất bại sau {max_retries} lần thử!", style="bold red")
return None

@ -0,0 +1,215 @@
"""
Facebook Uploader - Upload video lên Facebook sử dụng Graph API.
Yêu cầu:
- Facebook Developer App
- Page Access Token (cho Page upload) hoặc User Access Token
- Permissions: publish_video, pages_manage_posts
Docs: https://developers.facebook.com/docs/video-api/guides/publishing
"""
import json
import os
import time
from typing import Optional
import requests
from uploaders.base_uploader import BaseUploader, VideoMetadata
from utils import settings
from utils.console import print_substep
class FacebookUploader(BaseUploader):
"""Upload video lên Facebook Page/Profile."""
platform_name = "Facebook"
# Facebook API endpoints
GRAPH_API_BASE = "https://graph.facebook.com/v21.0"
# Limits
MAX_DESCRIPTION_LENGTH = 63206
MAX_TITLE_LENGTH = 255
MAX_FILE_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB
def __init__(self):
super().__init__()
self.config = settings.config.get("uploaders", {}).get("facebook", {})
self.access_token = None
self.page_id = None
def authenticate(self) -> bool:
"""Xác thực với Facebook Graph API.
Sử dụng Page Access Token cho upload lên Page.
Returns:
True nếu xác thực thành công.
"""
self.access_token = self.config.get("access_token", "")
self.page_id = self.config.get("page_id", "")
if not self.access_token:
print_substep("Facebook: Thiếu access_token", style="bold red")
return False
if not self.page_id:
print_substep("Facebook: Thiếu page_id", style="bold red")
return False
# Verify token
try:
response = requests.get(
f"{self.GRAPH_API_BASE}/me",
params={"access_token": self.access_token},
timeout=15,
)
response.raise_for_status()
data = response.json()
if "id" in data:
self._authenticated = True
print_substep(f"Facebook: Xác thực thành công (Page: {data.get('name', self.page_id)}) ✅", style="bold green")
return True
else:
print_substep("Facebook: Token không hợp lệ", style="bold red")
return False
except Exception as e:
print_substep(f"Facebook: Lỗi xác thực - {e}", style="bold red")
return False
def upload(self, metadata: VideoMetadata) -> Optional[str]:
"""Upload video lên Facebook Page.
Sử dụng Resumable Upload API cho file lớn.
Args:
metadata: VideoMetadata chứa thông tin video.
Returns:
URL video trên Facebook, hoặc None nếu thất bại.
"""
if not self.access_token or not self.page_id:
return None
file_size = os.path.getsize(metadata.file_path)
title = metadata.title[:self.MAX_TITLE_LENGTH]
description = self._build_description(metadata)
# Step 1: Initialize upload session
try:
init_response = requests.post(
f"{self.GRAPH_API_BASE}/{self.page_id}/videos",
data={
"upload_phase": "start",
"file_size": file_size,
"access_token": self.access_token,
},
timeout=30,
)
init_response.raise_for_status()
init_data = init_response.json()
upload_session_id = init_data.get("upload_session_id", "")
video_id = init_data.get("video_id", "")
if not upload_session_id:
print_substep("Facebook: Không thể khởi tạo upload session", style="bold red")
return None
except Exception as e:
print_substep(f"Facebook: Lỗi khởi tạo upload - {e}", style="bold red")
return None
# Step 2: Upload video chunks
try:
chunk_size = 4 * 1024 * 1024 # 4 MB chunks
start_offset = 0
with open(metadata.file_path, "rb") as video_file:
while start_offset < file_size:
chunk = video_file.read(chunk_size)
transfer_response = requests.post(
f"{self.GRAPH_API_BASE}/{self.page_id}/videos",
data={
"upload_phase": "transfer",
"upload_session_id": upload_session_id,
"start_offset": start_offset,
"access_token": self.access_token,
},
files={"video_file_chunk": ("chunk", chunk, "application/octet-stream")},
timeout=120,
)
transfer_response.raise_for_status()
transfer_data = transfer_response.json()
start_offset = int(transfer_data.get("start_offset", file_size))
end_offset = int(transfer_data.get("end_offset", file_size))
if start_offset >= file_size:
break
except Exception as e:
print_substep(f"Facebook: Lỗi upload file - {e}", style="bold red")
return None
# Step 3: Finish upload
try:
finish_data = {
"upload_phase": "finish",
"upload_session_id": upload_session_id,
"access_token": self.access_token,
"title": title,
"description": description[:self.MAX_DESCRIPTION_LENGTH],
}
if metadata.schedule_time:
finish_data["scheduled_publish_time"] = metadata.schedule_time
finish_data["published"] = "false"
if metadata.thumbnail_path and os.path.exists(metadata.thumbnail_path):
with open(metadata.thumbnail_path, "rb") as thumb:
finish_response = requests.post(
f"{self.GRAPH_API_BASE}/{self.page_id}/videos",
data=finish_data,
files={"thumb": thumb},
timeout=60,
)
else:
finish_response = requests.post(
f"{self.GRAPH_API_BASE}/{self.page_id}/videos",
data=finish_data,
timeout=60,
)
finish_response.raise_for_status()
finish_result = finish_response.json()
if finish_result.get("success", False):
video_url = f"https://www.facebook.com/{self.page_id}/videos/{video_id}"
return video_url
else:
print_substep("Facebook: Upload hoàn tất nhưng không thành công", style="bold red")
return None
except Exception as e:
print_substep(f"Facebook: Lỗi kết thúc upload - {e}", style="bold red")
return None
def _build_description(self, metadata: VideoMetadata) -> str:
"""Tạo description cho video Facebook."""
parts = []
if metadata.description:
parts.append(metadata.description)
if metadata.hashtags:
hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags)
parts.append(hashtag_str)
parts.append("")
parts.append("🎬 Video được tạo tự động bởi Threads Video Maker Bot")
return "\n".join(parts)

@ -0,0 +1,223 @@
"""
TikTok Uploader - Upload video lên TikTok sử dụng Content Posting API.
Yêu cầu:
- TikTok Developer App
- Content Posting API access
- OAuth2 access token
Docs: https://developers.tiktok.com/doc/content-posting-api-get-started
"""
import json
import os
import time
from typing import Optional
import requests
from uploaders.base_uploader import BaseUploader, VideoMetadata
from utils import settings
from utils.console import print_substep
class TikTokUploader(BaseUploader):
"""Upload video lên TikTok."""
platform_name = "TikTok"
# TikTok API endpoints
API_BASE = "https://open.tiktokapis.com/v2"
TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"
# Limits
MAX_CAPTION_LENGTH = 2200
MAX_FILE_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
MIN_DURATION = 3 # seconds
MAX_DURATION = 600 # 10 minutes
def __init__(self):
super().__init__()
self.config = settings.config.get("uploaders", {}).get("tiktok", {})
self.access_token = None
def authenticate(self) -> bool:
"""Xác thực với TikTok API sử dụng refresh token.
Returns:
True nếu xác thực thành công.
"""
client_key = self.config.get("client_key", "")
client_secret = self.config.get("client_secret", "")
refresh_token = self.config.get("refresh_token", "")
if not all([client_key, client_secret, refresh_token]):
print_substep(
"TikTok: Thiếu credentials (client_key, client_secret, refresh_token)",
style="bold red",
)
return False
try:
response = requests.post(self.TOKEN_URL, json={
"client_key": client_key,
"client_secret": client_secret,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}, timeout=30)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data.get("data", {}).get("access_token", "")
if self.access_token:
self._authenticated = True
print_substep("TikTok: Xác thực thành công! ✅", style="bold green")
return True
else:
print_substep("TikTok: Không lấy được access token", style="bold red")
return False
except Exception as e:
print_substep(f"TikTok: Lỗi xác thực - {e}", style="bold red")
return False
def upload(self, metadata: VideoMetadata) -> Optional[str]:
"""Upload video lên TikTok sử dụng Content Posting API.
Flow:
1. Initialize upload get upload_url
2. Upload video file to upload_url
3. Publish video
Args:
metadata: VideoMetadata chứa thông tin video.
Returns:
URL video trên TikTok, hoặc None nếu thất bại.
"""
if not self.access_token:
return None
file_size = os.path.getsize(metadata.file_path)
if file_size > self.MAX_FILE_SIZE:
print_substep(f"TikTok: File quá lớn ({file_size} bytes)", style="bold red")
return None
# Build caption
caption = self._build_caption(metadata)
# Step 1: Initialize upload
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json; charset=UTF-8",
}
init_body = {
"post_info": {
"title": caption,
"privacy_level": self._map_privacy(metadata.privacy),
"disable_duet": False,
"disable_comment": False,
"disable_stitch": False,
},
"source_info": {
"source": "FILE_UPLOAD",
"video_size": file_size,
"chunk_size": file_size, # Single chunk upload
"total_chunk_count": 1,
},
}
if metadata.schedule_time:
init_body["post_info"]["schedule_time"] = metadata.schedule_time
try:
init_response = requests.post(
f"{self.API_BASE}/post/publish/inbox/video/init/",
headers=headers,
json=init_body,
timeout=30,
)
init_response.raise_for_status()
init_data = init_response.json()
publish_id = init_data.get("data", {}).get("publish_id", "")
upload_url = init_data.get("data", {}).get("upload_url", "")
if not upload_url:
print_substep("TikTok: Không lấy được upload URL", style="bold red")
return None
except Exception as e:
print_substep(f"TikTok: Lỗi khởi tạo upload - {e}", style="bold red")
return None
# Step 2: Upload video file
try:
with open(metadata.file_path, "rb") as video_file:
upload_headers = {
"Content-Type": "video/mp4",
"Content-Length": str(file_size),
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
}
upload_response = requests.put(
upload_url,
headers=upload_headers,
data=video_file,
timeout=600,
)
upload_response.raise_for_status()
except Exception as e:
print_substep(f"TikTok: Lỗi upload file - {e}", style="bold red")
return None
# Step 3: Check publish status
status_url = f"{self.API_BASE}/post/publish/status/fetch/"
for attempt in range(10):
try:
status_response = requests.post(
status_url,
headers=headers,
json={"publish_id": publish_id},
timeout=30,
)
status_data = status_response.json()
status = status_data.get("data", {}).get("status", "")
if status == "PUBLISH_COMPLETE":
print_substep("TikTok: Upload thành công! ✅", style="bold green")
return f"https://www.tiktok.com/@user/video/{publish_id}"
elif status == "FAILED":
reason = status_data.get("data", {}).get("fail_reason", "Unknown")
print_substep(f"TikTok: Upload thất bại - {reason}", style="bold red")
return None
time.sleep(5) # Wait 5 seconds before checking again
except Exception:
time.sleep(5)
print_substep("TikTok: Upload timeout", style="bold yellow")
return None
def _build_caption(self, metadata: VideoMetadata) -> str:
"""Tạo caption cho video TikTok."""
parts = []
if metadata.title:
parts.append(metadata.title)
if metadata.hashtags:
hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags)
parts.append(hashtag_str)
caption = " ".join(parts)
return caption[:self.MAX_CAPTION_LENGTH]
@staticmethod
def _map_privacy(privacy: str) -> str:
"""Map privacy setting to TikTok format."""
mapping = {
"public": "PUBLIC_TO_EVERYONE",
"private": "SELF_ONLY",
"friends": "MUTUAL_FOLLOW_FRIENDS",
"unlisted": "SELF_ONLY",
}
return mapping.get(privacy, "PUBLIC_TO_EVERYONE")

@ -0,0 +1,137 @@
"""
Upload Manager - Quản upload video lên nhiều platform cùng lúc.
"""
import os
from typing import Dict, List, Optional
from uploaders.base_uploader import BaseUploader, VideoMetadata
from uploaders.youtube_uploader import YouTubeUploader
from uploaders.tiktok_uploader import TikTokUploader
from uploaders.facebook_uploader import FacebookUploader
from utils import settings
from utils.console import print_step, print_substep
class UploadManager:
"""Quản lý upload video lên nhiều platform."""
PLATFORM_MAP = {
"youtube": YouTubeUploader,
"tiktok": TikTokUploader,
"facebook": FacebookUploader,
}
def __init__(self):
self.uploaders: Dict[str, BaseUploader] = {}
self._init_uploaders()
def _init_uploaders(self):
"""Khởi tạo uploaders dựa trên cấu hình."""
upload_config = settings.config.get("uploaders", {})
for platform_name, uploader_class in self.PLATFORM_MAP.items():
platform_config = upload_config.get(platform_name, {})
if platform_config.get("enabled", False):
self.uploaders[platform_name] = uploader_class()
print_substep(f"Đã kích hoạt uploader: {platform_name}", style="bold blue")
def upload_to_all(
self,
video_path: str,
title: str,
description: str = "",
tags: Optional[List[str]] = None,
hashtags: Optional[List[str]] = None,
thumbnail_path: Optional[str] = None,
schedule_time: Optional[str] = None,
privacy: str = "public",
) -> Dict[str, Optional[str]]:
"""Upload video lên tất cả platform đã cấu hình.
Args:
video_path: Đường dẫn file video.
title: Tiêu đề video.
description: tả video.
tags: Danh sách tags.
hashtags: Danh sách hashtags.
thumbnail_path: Đường dẫn thumbnail.
schedule_time: Thời gian lên lịch (ISO 8601).
privacy: Quyền riêng (public/private/unlisted).
Returns:
Dict mapping platform name -> video URL (hoặc None nếu thất bại).
"""
if not self.uploaders:
print_substep("Không có uploader nào được kích hoạt!", style="bold yellow")
return {}
metadata = VideoMetadata(
file_path=video_path,
title=title,
description=description,
tags=tags or [],
hashtags=hashtags or self._default_hashtags(),
thumbnail_path=thumbnail_path,
schedule_time=schedule_time,
privacy=privacy,
language="vi",
)
results = {}
print_step(f"Đang upload video lên {len(self.uploaders)} platform...")
for platform_name, uploader in self.uploaders.items():
print_step(f"📤 Đang upload lên {platform_name}...")
url = uploader.safe_upload(metadata)
results[platform_name] = url
# Summary
print_step("📊 Kết quả upload:")
success_count = 0
for platform, url in results.items():
if url:
print_substep(f"{platform}: {url}", style="bold green")
success_count += 1
else:
print_substep(f"{platform}: Thất bại", style="bold red")
print_substep(
f"Upload hoàn tất: {success_count}/{len(results)} platform thành công",
style="bold blue",
)
return results
def upload_to_platform(
self,
platform_name: str,
metadata: VideoMetadata,
) -> Optional[str]:
"""Upload video lên một platform cụ thể.
Args:
platform_name: Tên platform.
metadata: VideoMetadata chứa thông tin video.
Returns:
URL video, hoặc None nếu thất bại.
"""
if platform_name not in self.uploaders:
print_substep(f"Platform '{platform_name}' chưa được kích hoạt!", style="bold red")
return None
return self.uploaders[platform_name].safe_upload(metadata)
@staticmethod
def _default_hashtags() -> List[str]:
"""Hashtags mặc định cho thị trường Việt Nam."""
return [
"threads",
"viral",
"vietnam",
"trending",
"foryou",
"fyp",
"threadsvn",
]

@ -0,0 +1,219 @@
"""
YouTube Uploader - Upload video lên YouTube sử dụng YouTube Data API v3.
Yêu cầu:
- Google API credentials (OAuth2)
- YouTube Data API v3 enabled
- Scopes: https://www.googleapis.com/auth/youtube.upload
"""
import os
import json
import time
from typing import Optional
import requests
from uploaders.base_uploader import BaseUploader, VideoMetadata
from utils import settings
from utils.console import print_substep
class YouTubeUploader(BaseUploader):
"""Upload video lên YouTube."""
platform_name = "YouTube"
# YouTube API endpoints
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
TOKEN_URL = "https://oauth2.googleapis.com/token"
# Limits
MAX_TITLE_LENGTH = 100
MAX_DESCRIPTION_LENGTH = 5000
MAX_TAGS = 500 # Total characters for all tags
MAX_FILE_SIZE = 256 * 1024 * 1024 * 1024 # 256 GB
def __init__(self):
super().__init__()
self.config = settings.config.get("uploaders", {}).get("youtube", {})
self.access_token = None
def authenticate(self) -> bool:
"""Xác thực với YouTube API sử dụng refresh token.
Cấu hình cần :
- client_id
- client_secret
- refresh_token (lấy từ OAuth2 flow)
Returns:
True nếu xác thực thành công.
"""
client_id = self.config.get("client_id", "")
client_secret = self.config.get("client_secret", "")
refresh_token = self.config.get("refresh_token", "")
if not all([client_id, client_secret, refresh_token]):
print_substep(
"YouTube: Thiếu credentials (client_id, client_secret, refresh_token)",
style="bold red",
)
return False
try:
response = requests.post(self.TOKEN_URL, data={
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
}, timeout=30)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data["access_token"]
self._authenticated = True
print_substep("YouTube: Xác thực thành công! ✅", style="bold green")
return True
except Exception as e:
print_substep(f"YouTube: Lỗi xác thực - {e}", style="bold red")
return False
def upload(self, metadata: VideoMetadata) -> Optional[str]:
"""Upload video lên YouTube.
Args:
metadata: VideoMetadata chứa thông tin video.
Returns:
URL video trên YouTube, hoặc None nếu thất bại.
"""
if not self.access_token:
return None
title = metadata.title[:self.MAX_TITLE_LENGTH]
description = self._build_description(metadata)
tags = metadata.tags or []
# Thêm hashtags vào description
if metadata.hashtags:
hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags)
description = f"{description}\n\n{hashtag_str}"
# Video metadata
video_metadata = {
"snippet": {
"title": title,
"description": description[:self.MAX_DESCRIPTION_LENGTH],
"tags": tags,
"categoryId": self._get_category_id(metadata.category),
"defaultLanguage": metadata.language,
"defaultAudioLanguage": metadata.language,
},
"status": {
"privacyStatus": metadata.privacy,
"selfDeclaredMadeForKids": False,
},
}
# Schedule publish time
if metadata.schedule_time and metadata.privacy != "public":
video_metadata["status"]["publishAt"] = metadata.schedule_time
video_metadata["status"]["privacyStatus"] = "private"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
# Step 1: Initiate resumable upload
params = {
"uploadType": "resumable",
"part": "snippet,status",
}
init_response = requests.post(
self.UPLOAD_URL,
headers=headers,
params=params,
json=video_metadata,
timeout=30,
)
init_response.raise_for_status()
upload_url = init_response.headers.get("Location")
if not upload_url:
print_substep("YouTube: Không thể khởi tạo upload session", style="bold red")
return None
# Step 2: Upload video file
file_size = os.path.getsize(metadata.file_path)
with open(metadata.file_path, "rb") as video_file:
upload_response = requests.put(
upload_url,
headers={
"Content-Type": "video/mp4",
"Content-Length": str(file_size),
},
data=video_file,
timeout=600, # 10 minutes timeout for large files
)
upload_response.raise_for_status()
video_data = upload_response.json()
video_id = video_data.get("id", "")
if not video_id:
print_substep("YouTube: Upload thành công nhưng không lấy được video ID", style="bold yellow")
return None
# Step 3: Upload thumbnail if available
if metadata.thumbnail_path and os.path.exists(metadata.thumbnail_path):
self._upload_thumbnail(video_id, metadata.thumbnail_path)
video_url = f"https://www.youtube.com/watch?v={video_id}"
return video_url
def _upload_thumbnail(self, video_id: str, thumbnail_path: str):
"""Upload thumbnail cho video."""
try:
url = f"https://www.googleapis.com/upload/youtube/v3/thumbnails/set"
with open(thumbnail_path, "rb") as thumb_file:
response = requests.post(
url,
headers={"Authorization": f"Bearer {self.access_token}"},
params={"videoId": video_id},
files={"media": thumb_file},
timeout=60,
)
response.raise_for_status()
print_substep("YouTube: Đã upload thumbnail ✅", style="bold green")
except Exception as e:
print_substep(f"YouTube: Lỗi upload thumbnail - {e}", style="bold yellow")
def _build_description(self, metadata: VideoMetadata) -> str:
"""Tạo description cho video YouTube."""
parts = []
if metadata.description:
parts.append(metadata.description)
parts.append("")
parts.append("🎬 Video được tạo tự động bởi Threads Video Maker Bot")
parts.append(f"🌐 Ngôn ngữ: Tiếng Việt")
return "\n".join(parts)
@staticmethod
def _get_category_id(category: str) -> str:
"""Map category name to YouTube category ID."""
categories = {
"Entertainment": "24",
"People & Blogs": "22",
"Comedy": "23",
"Education": "27",
"Science & Technology": "28",
"News & Politics": "25",
"Gaming": "20",
"Music": "10",
}
return categories.get(category, "24")

@ -1,63 +1,107 @@
[reddit.creds]
client_id = { optional = false, nmin = 12, nmax = 30, explanation = "The ID of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The ID should be over 12 and under 30 characters, double check your input." }
client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "The SECRET of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The secret should be over 20 and under 40 characters, double check your input." }
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 }
# ===== THREADS CONFIGURATION (thay thế Reddit cho thị trường Việt Nam) =====
[threads.creds]
access_token = { optional = false, nmin = 10, explanation = "Threads API access token (lấy từ Meta Developer Portal)", example = "THR_abc123def456" }
user_id = { optional = false, nmin = 1, explanation = "Threads user ID của bạn", example = "12345678" }
[reddit.thread]
random = { optional = true, options = [true, false, ], default = false, type = "bool", explanation = "If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'", example = "True" }
subreddit = { optional = false, regex = "[_0-9a-zA-Z\\+]+$", nmin = 3, explanation = "What subreddit to pull posts from, the name of the sub, not the URL. You can have multiple subreddits, add an + with no spaces.", example = "AskReddit+Redditdev", oob_error = "A subreddit name HAS to be between 3 and 20 characters" }
post_id = { optional = true, default = "", regex = "^((?!://|://)[+a-zA-Z0-9])*$", explanation = "Used if you want to use a specific post.", example = "urdtfx" }
max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "max number of characters a comment can have. default is 500", example = 500, oob_error = "the max comment length should be between 10 and 10000" }
min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "min_comment_length number of characters a comment can have. default is 0", example = 50, oob_error = "the max comment length should be between 1 and 100" }
post_lang = { default = "", optional = true, explanation = "The language you would like to translate to.", 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'] }
min_comments = { default = 20, optional = false, nmin = 10, type = "int", explanation = "The minimum number of comments a post should have to be included. default is 20", example = 29, oob_error = "the minimum number of comments should be between 15 and 999999" }
blocked_words = { optional = true, default = "", type = "str", explanation = "Comma-separated list of words/phrases. Posts and comments containing any of these will be skipped.", example = "nsfw, spoiler, politics" }
[threads.thread]
target_user_id = { optional = true, default = "", explanation = "ID user muốn lấy threads. Để trống dùng user của bạn.", example = "87654321" }
post_id = { optional = true, default = "", explanation = "ID cụ thể của thread. Để trống để tự động chọn.", example = "18050000000000000" }
keywords = { optional = true, default = "", type = "str", explanation = "Từ khóa lọc threads, phân cách bằng dấu phẩy.", example = "viral, trending, hài hước" }
max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "Độ dài tối đa reply (ký tự). Mặc định: 500", example = 500, oob_error = "Phải trong khoảng 10-10000" }
min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "Độ dài tối thiểu reply. Mặc định: 1", example = 10 }
post_lang = { default = "vi", optional = true, explanation = "Ngôn ngữ. Mặc định: vi (Tiếng Việt)", example = "vi", options = ['vi', 'en', 'zh-CN', 'ja', 'ko', 'th', ''] }
min_comments = { default = 5, optional = false, nmin = 1, type = "int", explanation = "Số replies tối thiểu. Mặc định: 5", example = 5 }
blocked_words = { optional = true, default = "", type = "str", explanation = "Từ bị chặn, phân cách bằng dấu phẩy.", example = "spam, quảng cáo" }
channel_name = { optional = true, default = "Threads Vietnam", example = "Threads VN Stories", explanation = "Tên kênh hiển thị trên video" }
[ai]
ai_similarity_enabled = {optional = true, option = [true, false], default = false, type = "bool", explanation = "Threads read from Reddit are sorted based on their similarity to the keywords given below"}
ai_similarity_keywords = {optional = true, type="str", example= 'Elon Musk, Twitter, Stocks', explanation = "Every keyword or even sentence, seperated with comma, is used to sort the reddit threads based on similarity"}
ai_similarity_enabled = { optional = true, option = [true, false], default = false, type = "bool", explanation = "Sắp xếp threads theo độ tương đồng với từ khóa" }
ai_similarity_keywords = { optional = true, type = "str", example = "viral, trending, hài hước", explanation = "Từ khóa sắp xếp threads theo độ tương đồng" }
[settings]
allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false, ], explanation = "Whether to allow NSFW content, True or False" }
theme = { optional = false, default = "dark", example = "light", options = ["dark", "light", "transparent", ], explanation = "Sets the Reddit theme, either LIGHT or DARK. For story mode you can also use a transparent background." }
times_to_run = { optional = false, default = 1, example = 2, explanation = "Used if you want to run multiple times. Set to an int e.g. 4 or 29 or 1", type = "int", nmin = 1, oob_error = "It's very hard to run something less than once." }
opacity = { optional = false, default = 0.9, example = 0.8, explanation = "Sets the opacity of the comments when overlayed over the background", type = "float", nmin = 0, nmax = 1, oob_error = "The opacity HAS to be between 0 and 1", input_error = "The opacity HAS to be a decimal number between 0 and 1" }
#transition = { optional = true, default = 0.2, example = 0.2, explanation = "Sets the transition time (in seconds) between the comments. Set to 0 if you want to disable it.", type = "float", nmin = 0, nmax = 2, oob_error = "The transition HAS to be between 0 and 2", input_error = "The opacity HAS to be a decimal number between 0 and 2" }
storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Only read out title and post content, great for subreddits with stories" }
storymodemethod= { optional = true, default = 1, example = 1, explanation = "Style that's used for the storymode. Set to 0 for single picture display in whole video, set to 1 for fancy looking video ", type = "int", nmin = 0, oob_error = "It's very hard to run something less than once.", options = [0, 1] }
storymode_max_length = { optional = true, default = 1000, example = 1000, explanation = "Max length of the storymode video in characters. 200 characters are approximately 50 seconds.", type = "int", nmin = 1, oob_error = "It's very hard to make a video under a second." }
resolution_w = { optional = false, default = 1080, example = 1440, explantation = "Sets the width in pixels of the final video" }
resolution_h = { optional = false, default = 1920, example = 2560, explantation = "Sets the height in pixels of the final video" }
zoom = { optional = true, default = 1, example = 1.1, explanation = "Sets the browser zoom level. Useful if you want the text larger.", type = "float", nmin = 0.1, nmax = 2, oob_error = "The text is really difficult to read at a zoom level higher than 2" }
channel_name = { optional = true, default = "Reddit Tales", example = "Reddit Stories", explanation = "Sets the channel name for the video" }
allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Cho phép nội dung NSFW" }
theme = { optional = false, default = "dark", example = "dark", options = ["dark", "light"], explanation = "Giao diện Threads: DARK hoặc LIGHT" }
times_to_run = { optional = false, default = 1, example = 2, explanation = "Số video tạo mỗi lần chạy", type = "int", nmin = 1, oob_error = "Phải >= 1" }
opacity = { optional = false, default = 0.9, example = 0.8, explanation = "Độ trong suốt hình ảnh trên video", type = "float", nmin = 0, nmax = 1, oob_error = "Phải trong khoảng 0-1" }
storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false], explanation = "Chế độ đọc truyện" }
storymodemethod = { optional = true, default = 1, example = 1, type = "int", nmin = 0, options = [0, 1], explanation = "Kiểu story mode. 0: 1 hình, 1: nhiều hình" }
storymode_max_length = { optional = true, default = 1000, example = 1000, type = "int", nmin = 1, explanation = "Độ dài tối đa story (ký tự)" }
resolution_w = { optional = false, default = 1080, example = 1080, explantation = "Chiều rộng video (pixels)" }
resolution_h = { optional = false, default = 1920, example = 1920, explantation = "Chiều cao video (pixels)" }
zoom = { optional = true, default = 1, example = 1.1, type = "float", nmin = 0.1, nmax = 2, explanation = "Mức zoom text" }
channel_name = { optional = true, default = "Threads Vietnam", example = "Threads VN", explanation = "Tên kênh" }
[settings.background]
background_video = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2","multiversus","fall-guys","steep", ""], explanation = "Sets the background for the video based on game name" }
background_audio = { optional = true, default = "lofi", example = "chill-summer", options = ["lofi","lofi-2","chill-summer",""], explanation = "Sets the background audio for the video" }
background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation="Sets the volume of the background audio. If you don't want background audio, set it to 0.", oob_error = "The volume HAS to be between 0 and 1", input_error = "The volume HAS to be a float number between 0 and 1"}
enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation="Used if you want to render another video without background audio in a separate folder", input_error = "The value HAS to be true or false"}
background_thumbnail = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Generate a thumbnail for the video (put a thumbnail.png file in the assets/backgrounds directory.)" }
background_thumbnail_font_family = { optional = true, default = "arial", example = "arial", explanation = "Font family for the thumbnail text" }
background_thumbnail_font_size = { optional = true, type = "int", default = 96, example = 96, explanation = "Font size in pixels for the thumbnail text" }
background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Font color in RGB format for the thumbnail text" }
background_video = { optional = true, default = "minecraft", example = "minecraft", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2", "multiversus", "fall-guys", "steep", ""], explanation = "Video nền" }
background_audio = { optional = true, default = "lofi", example = "lofi", options = ["lofi", "lofi-2", "chill-summer", ""], explanation = "Nhạc nền" }
background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation = "Âm lượng nhạc nền (0-1)" }
enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation = "Tạo thêm video chỉ có TTS" }
background_thumbnail = { optional = true, type = "bool", default = false, example = false, options = [true, false], explanation = "Tạo thumbnail tự động" }
background_thumbnail_font_family = { optional = true, default = "arial", example = "arial", explanation = "Font thumbnail" }
background_thumbnail_font_size = { optional = true, type = "int", default = 96, example = 96, explanation = "Cỡ chữ thumbnail" }
background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Màu chữ thumbnail (RGB)" }
[settings.tts]
voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "tiktok", explanation = "The voice platform used for TTS generation. " }
random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" }
elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] }
elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" }
aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for AWS Polly" }
streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" }
tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "The voice used for TikTok TTS" }
tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok sessionid needed if you're using the TikTok TTS. Check documentation if you don't know how to obtain it." }
python_voice = { optional = false, default = "1", example = "1", explanation = "The index of the system tts voices (can be downloaded externally, run ptt.py to find value, start from zero)" }
py_voice_num = { optional = false, default = "2", example = "2", explanation = "The number of system voices (2 are pre-installed in Windows)" }
silence_duration = { optional = true, example = "0.1", explanation = "Time in seconds between TTS comments", default = 0.3, type = "float" }
no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false,], explanation = "Whether to remove emojis from the comments" }
openai_api_url = { optional = true, default = "https://api.openai.com/v1/", example = "https://api.openai.com/v1/", explanation = "The API endpoint URL for OpenAI TTS generation" }
openai_api_key = { optional = true, example = "sk-abc123def456...", explanation = "Your OpenAI API key for TTS generation" }
openai_voice_name = { optional = false, default = "alloy", example = "alloy", explanation = "The voice used for OpenAI TTS generation", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"] }
openai_model = { optional = false, default = "tts-1", example = "tts-1", explanation = "The model variant used for OpenAI TTS generation", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] }
voice_choice = { optional = false, default = "googletranslate", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "googletranslate", explanation = "Engine TTS. googletranslate hỗ trợ tiếng Việt tốt nhất" }
random_voice = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Ngẫu nhiên giọng đọc" }
elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam"], explanation = "Giọng ElevenLabs" }
elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "ElevenLabs API key" }
aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "Giọng AWS Polly" }
streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "Giọng Streamlabs Polly" }
tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "Giọng TikTok TTS" }
tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok session ID" }
python_voice = { optional = false, default = "1", example = "1", explanation = "Index giọng hệ thống" }
py_voice_num = { optional = false, default = "2", example = "2", explanation = "Số giọng hệ thống" }
silence_duration = { optional = true, example = "0.1", default = 0.3, type = "float", explanation = "Khoảng lặng giữa TTS (giây)" }
no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Loại bỏ emoji" }
openai_api_url = { optional = true, default = "https://api.openai.com/v1/", example = "https://api.openai.com/v1/", explanation = "OpenAI API URL" }
openai_api_key = { optional = true, example = "sk-abc123...", explanation = "OpenAI API key" }
openai_voice_name = { optional = false, default = "alloy", example = "alloy", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"], explanation = "Giọng OpenAI TTS" }
openai_model = { optional = false, default = "tts-1", example = "tts-1", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], explanation = "Model OpenAI TTS" }
# ===== AUTO-UPLOAD PLATFORMS =====
[uploaders.youtube]
enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload YouTube" }
client_id = { optional = true, default = "", explanation = "Google OAuth2 Client ID" }
client_secret = { optional = true, default = "", explanation = "Google OAuth2 Client Secret" }
refresh_token = { optional = true, default = "", explanation = "Google OAuth2 Refresh Token" }
[uploaders.tiktok]
enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload TikTok" }
client_key = { optional = true, default = "", explanation = "TikTok Client Key" }
client_secret = { optional = true, default = "", explanation = "TikTok Client Secret" }
refresh_token = { optional = true, default = "", explanation = "TikTok Refresh Token" }
[uploaders.facebook]
enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload Facebook" }
page_id = { optional = true, default = "", explanation = "Facebook Page ID" }
access_token = { optional = true, default = "", explanation = "Facebook Page Access Token" }
# ===== SCHEDULER =====
[scheduler]
enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật lên lịch tự động" }
cron = { optional = true, default = "0 */6 * * *", explanation = "Cron expression. Mặc định: mỗi 6 giờ", example = "0 8,14,20 * * *" }
timezone = { optional = true, default = "Asia/Ho_Chi_Minh", explanation = "Múi giờ", example = "Asia/Ho_Chi_Minh" }
max_videos_per_day = { optional = true, default = 4, type = "int", nmin = 1, nmax = 20, explanation = "Số video tối đa/ngày" }
# ===== LEGACY REDDIT CONFIG =====
[reddit.creds]
client_id = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit client ID" }
client_secret = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit client secret" }
username = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit username" }
password = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit password" }
2fa = { optional = true, type = "bool", options = [true, false], default = false, explanation = "(Legacy) Reddit 2FA" }
[reddit.thread]
random = { optional = true, options = [true, false], default = false, type = "bool", explanation = "(Legacy)" }
subreddit = { optional = true, default = "AskReddit", nmin = 0, explanation = "(Legacy)" }
post_id = { optional = true, default = "", explanation = "(Legacy)" }
max_comment_length = { default = 500, optional = true, nmin = 10, nmax = 10000, type = "int", explanation = "(Legacy)" }
min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "(Legacy)" }
post_lang = { default = "vi", optional = true, explanation = "(Legacy)", options = ['','vi','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'] }
min_comments = { default = 20, optional = true, nmin = 1, type = "int", explanation = "(Legacy)" }
blocked_words = { optional = true, default = "", type = "str", explanation = "(Legacy)" }

@ -1,34 +1,38 @@
import json
import time
from praw.models import Submission
from utils import settings
from utils.console import print_step
def check_done(
redditobj: Submission,
) -> Submission:
redditobj,
):
# don't set this to be run anyplace that isn't subreddit.py bc of inspect stack
"""Checks if the chosen post has already been generated
Args:
redditobj (Submission): Reddit object gotten from reddit/subreddit.py
redditobj: Reddit/Threads submission object
Returns:
Submission|None: Reddit object in args
The object if not done, None if already done
"""
with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw:
done_videos = json.load(done_vids_raw)
for video in done_videos:
if video["id"] == str(redditobj):
if settings.config["reddit"]["thread"]["post_id"]:
# Check both threads and reddit config for post_id
post_id = ""
if "threads" in settings.config and "thread" in settings.config["threads"]:
post_id = settings.config["threads"]["thread"].get("post_id", "")
if not post_id and "reddit" in settings.config and "thread" in settings.config["reddit"]:
post_id = settings.config["reddit"]["thread"].get("post_id", "")
if post_id:
print_step(
"You already have done this video but since it was declared specifically in the config file the program will continue"
"Video đã được tạo trước đó nhưng được chỉ định cụ thể trong config, tiếp tục..."
)
return redditobj
print_step("Getting new post as the current one has already been done")
print_step("Đang lấy bài viết mới vì bài này đã được tạo video")
return None
return redditobj

@ -75,7 +75,12 @@ def name_normalize(name: str) -> str:
name = re.sub(r"(\w+)\s?\/\s?(\w+)", r"\1 or \2", name)
name = re.sub(r"\/", r"", name)
lang = settings.config["reddit"]["thread"]["post_lang"]
# Support both Threads and Reddit config
lang = ""
if "threads" in settings.config and "thread" in settings.config["threads"]:
lang = settings.config["threads"]["thread"].get("post_lang", "")
if not lang and "reddit" in settings.config and "thread" in settings.config["reddit"]:
lang = settings.config["reddit"]["thread"].get("post_lang", "")
if lang:
print_substep("Translating filename...")
translated_name = translators.translate_text(name, translator="google", to_language=lang)
@ -359,7 +364,12 @@ def make_final_video(
title_thumb = reddit_obj["thread_title"]
filename = f"{name_normalize(title)[:251]}"
subreddit = settings.config["reddit"]["thread"]["subreddit"]
# Support both Threads and Reddit config for subreddit/channel name
subreddit = "threads"
if "threads" in settings.config and "thread" in settings.config["threads"]:
subreddit = settings.config["threads"]["thread"].get("channel_name", "threads")
elif "reddit" in settings.config and "thread" in settings.config["reddit"]:
subreddit = settings.config["reddit"]["thread"].get("subreddit", "threads")
if not exists(f"./results/{subreddit}"):
print_substep("The 'results' folder could not be found so it was automatically created.")

@ -0,0 +1,344 @@
"""
Threads Screenshot Generator - Tạo hình ảnh giả lập giao diện Threads.
Sử dụng Pillow để render hình ảnh thay chụp screenshot từ trình duyệt,
Threads không giao diện web tĩnh dễ chụp như Reddit.
"""
import os
import re
import textwrap
from pathlib import Path
from typing import Dict, Final, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from rich.progress import track
from utils import settings
from utils.console import print_step, print_substep
# Threads color themes
THEMES = {
"dark": {
"bg_color": (0, 0, 0),
"card_bg": (30, 30, 30),
"text_color": (255, 255, 255),
"secondary_text": (140, 140, 140),
"border_color": (50, 50, 50),
"accent_color": (0, 149, 246), # Threads blue
"reply_line": (60, 60, 60),
},
"light": {
"bg_color": (255, 255, 255),
"card_bg": (255, 255, 255),
"text_color": (0, 0, 0),
"secondary_text": (130, 130, 130),
"border_color": (219, 219, 219),
"accent_color": (0, 149, 246),
"reply_line": (200, 200, 200),
},
}
def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
"""Load font hỗ trợ tiếng Việt."""
font_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts")
if bold:
font_path = os.path.join(font_dir, "Roboto-Bold.ttf")
else:
font_path = os.path.join(font_dir, "Roboto-Medium.ttf")
try:
return ImageFont.truetype(font_path, size)
except (OSError, IOError):
return ImageFont.load_default()
def _wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]:
"""Wrap text to fit within max_width pixels."""
words = text.split()
lines = []
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
bbox = font.getbbox(test_line)
if bbox[2] <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
return lines if lines else [""]
def _draw_avatar(draw: ImageDraw.Draw, x: int, y: int, size: int, color: Tuple[int, ...]):
"""Vẽ avatar tròn placeholder."""
draw.ellipse([x, y, x + size, y + size], fill=color)
# Vẽ chữ cái đầu trong avatar
font = _get_font(size // 2, bold=True)
draw.text(
(x + size // 4, y + size // 6),
"T",
fill=(255, 255, 255),
font=font,
)
def create_thread_post_image(
thread_obj: dict,
theme_name: str = "dark",
width: int = 1080,
) -> Image.Image:
"""Tạo hình ảnh cho bài viết Threads chính (title/post).
Args:
thread_obj: Thread object chứa thông tin bài viết.
theme_name: Theme name ("dark" hoặc "light").
width: Chiều rộng hình ảnh.
Returns:
PIL Image object.
"""
theme = THEMES.get(theme_name, THEMES["dark"])
padding = 40
content_width = width - (padding * 2)
avatar_size = 60
# Fonts
username_font = _get_font(28, bold=True)
body_font = _get_font(32)
meta_font = _get_font(22)
author = thread_obj.get("thread_author", "@user")
text = thread_obj.get("thread_title", thread_obj.get("thread_post", ""))
# Tính chiều cao
text_lines = _wrap_text(text, body_font, content_width - avatar_size - 30)
line_height = 42
text_height = len(text_lines) * line_height
total_height = padding + avatar_size + 20 + text_height + 60 + padding
# Tạo image
img = Image.new("RGBA", (width, total_height), theme["bg_color"])
draw = ImageDraw.Draw(img)
y_cursor = padding
# Avatar
_draw_avatar(draw, padding, y_cursor, avatar_size, theme["accent_color"])
# Username
draw.text(
(padding + avatar_size + 15, y_cursor + 5),
author,
fill=theme["text_color"],
font=username_font,
)
# Timestamp
draw.text(
(padding + avatar_size + 15, y_cursor + 35),
"🧵 Threads",
fill=theme["secondary_text"],
font=meta_font,
)
y_cursor += avatar_size + 20
# Thread line (vertical line from avatar to content)
line_x = padding + avatar_size // 2
draw.line(
[(line_x, padding + avatar_size + 5), (line_x, y_cursor - 5)],
fill=theme["reply_line"],
width=3,
)
# Body text
for line in text_lines:
draw.text(
(padding + 10, y_cursor),
line,
fill=theme["text_color"],
font=body_font,
)
y_cursor += line_height
# Interaction bar
y_cursor += 20
icons = "❤️ 💬 🔄 ✈️"
draw.text(
(padding + 10, y_cursor),
icons,
fill=theme["secondary_text"],
font=meta_font,
)
return img
def create_comment_image(
comment: dict,
index: int,
theme_name: str = "dark",
width: int = 1080,
) -> Image.Image:
"""Tạo hình ảnh cho một reply/comment trên Threads.
Args:
comment: Comment dict.
index: Số thứ tự comment.
theme_name: Theme name.
width: Chiều rộng hình ảnh.
Returns:
PIL Image object.
"""
theme = THEMES.get(theme_name, THEMES["dark"])
padding = 40
content_width = width - (padding * 2)
avatar_size = 50
# Fonts
username_font = _get_font(24, bold=True)
body_font = _get_font(30)
meta_font = _get_font(20)
author = comment.get("comment_author", f"@user{index}")
text = comment.get("comment_body", "")
# Tính chiều cao
text_lines = _wrap_text(text, body_font, content_width - avatar_size - 30)
line_height = 40
text_height = len(text_lines) * line_height
total_height = padding + avatar_size + 15 + text_height + 40 + padding
# Tạo image
img = Image.new("RGBA", (width, total_height), theme["bg_color"])
draw = ImageDraw.Draw(img)
y_cursor = padding
# Reply line at top
draw.line(
[(padding, 0), (padding, y_cursor)],
fill=theme["reply_line"],
width=2,
)
# Avatar (smaller for comments)
colors = [
(88, 101, 242), # Blue
(237, 66, 69), # Red
(87, 242, 135), # Green
(254, 231, 92), # Yellow
(235, 69, 158), # Pink
]
avatar_color = colors[index % len(colors)]
_draw_avatar(draw, padding, y_cursor, avatar_size, avatar_color)
# Username
draw.text(
(padding + avatar_size + 12, y_cursor + 5),
author,
fill=theme["text_color"],
font=username_font,
)
# Time indicator
draw.text(
(padding + avatar_size + 12, y_cursor + 30),
"Trả lời",
fill=theme["secondary_text"],
font=meta_font,
)
y_cursor += avatar_size + 15
# Body text
for line in text_lines:
draw.text(
(padding + 10, y_cursor),
line,
fill=theme["text_color"],
font=body_font,
)
y_cursor += line_height
# Bottom separator
y_cursor += 10
draw.line(
[(padding, y_cursor), (width - padding, y_cursor)],
fill=theme["border_color"],
width=1,
)
return img
def get_screenshots_of_threads_posts(thread_object: dict, screenshot_num: int):
"""Tạo screenshots cho bài viết Threads.
Thay thế get_screenshots_of_reddit_posts() cho Threads.
Args:
thread_object: Thread object từ threads_client.py.
screenshot_num: Số lượng screenshots cần tạo.
"""
W: Final[int] = int(settings.config["settings"]["resolution_w"])
H: Final[int] = int(settings.config["settings"]["resolution_h"])
theme: str = settings.config["settings"].get("theme", "dark")
storymode: bool = settings.config["settings"].get("storymode", False)
print_step("Đang tạo hình ảnh cho bài viết Threads...")
thread_id = re.sub(r"[^\w\s-]", "", thread_object["thread_id"])
Path(f"assets/temp/{thread_id}/png").mkdir(parents=True, exist_ok=True)
# Tạo hình ảnh cho bài viết chính (title)
title_img = create_thread_post_image(
thread_object,
theme_name=theme if theme in THEMES else "dark",
width=W,
)
title_img.save(f"assets/temp/{thread_id}/png/title.png")
print_substep("Đã tạo hình ảnh tiêu đề", style="bold green")
if storymode:
# Story mode - chỉ cần 1 hình cho toàn bộ nội dung
story_img = create_thread_post_image(
{
"thread_author": thread_object.get("thread_author", "@user"),
"thread_title": thread_object.get("thread_post", ""),
},
theme_name=theme if theme in THEMES else "dark",
width=W,
)
story_img.save(f"assets/temp/{thread_id}/png/story_content.png")
else:
# Comment mode - tạo hình cho từng reply
comments = thread_object.get("comments", [])[:screenshot_num]
for idx, comment in enumerate(
track(comments, "Đang tạo hình ảnh replies...")
):
if idx >= screenshot_num:
break
comment_img = create_comment_image(
comment,
index=idx,
theme_name=theme if theme in THEMES else "dark",
width=W,
)
comment_img.save(f"assets/temp/{thread_id}/png/comment_{idx}.png")
print_substep("Đã tạo tất cả hình ảnh thành công! ✅", style="bold green")
Loading…
Cancel
Save