You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
220 lines
7.4 KiB
220 lines
7.4 KiB
"""
|
|
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 có:
|
|
- 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")
|