LLMVideoPipeline/pipeline/youtube_uploader.py
2026-08-16 09:39:36 -06:00

197 lines
7.6 KiB
Python

"""
YouTube Data API v3 Uploader Module.
Handles OAuth2 authentication, video uploading, custom thumbnail uploading,
multi-language captions (.srt), YouTube chapters embedding, and metadata publishing.
"""
import os
import json
import logging
from typing import Dict, Any, Optional, List
logger = logging.getLogger("YTFactory.YouTube")
SCOPES = [
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.force-ssl"
]
class YouTubeUploader:
def __init__(
self,
client_secrets_file: str = "client_secrets.json",
token_file: str = "youtube_token.json",
default_privacy: str = "private"
):
self.client_secrets_file = client_secrets_file
self.token_file = token_file
self.default_privacy = default_privacy
self.youtube_service = None
def is_configured(self) -> bool:
"""Check if OAuth client secrets file exists."""
return os.path.exists(self.client_secrets_file) or os.path.exists(self.token_file)
def get_service(self):
"""Authenticate and return YouTube API service client."""
if self.youtube_service:
return self.youtube_service
try:
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
creds = None
if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
if not os.path.exists(self.client_secrets_file):
raise FileNotFoundError(
f"YouTube OAuth secrets file '{self.client_secrets_file}' not found.\n"
"To enable YouTube API uploads, download your client_secrets.json from Google Cloud Console."
)
flow = InstalledAppFlow.from_client_secrets_file(self.client_secrets_file, SCOPES)
creds = flow.run_local_server(port=0)
with open(self.token_file, "w") as token:
token.write(creds.to_json())
self.youtube_service = build("youtube", "v3", credentials=creds)
return self.youtube_service
except Exception as e:
logger.error(f"[YOUTUBE] Authentication failed: {e}")
raise
def upload_video(
self,
video_path: str,
title: str,
description: str,
tags: List[str],
category_id: str = "27", # Education
privacy_status: Optional[str] = None
) -> str:
"""Upload video file to YouTube and return video ID."""
from googleapiclient.http import MediaFileUpload
service = self.get_service()
privacy = privacy_status or self.default_privacy
logger.info(f"[YOUTUBE] Uploading video '{title}' ({privacy})...")
body = {
"snippet": {
"title": title[:100],
"description": description,
"tags": tags,
"categoryId": category_id
},
"status": {
"privacyStatus": privacy,
"selfDeclaredMadeForKids": False
}
}
media = MediaFileUpload(video_path, chunksize=-1, resumable=True)
request = service.videos().insert(part="snippet,status", body=body, media_body=media)
response = None
while response is None:
status, response = request.next_chunk()
if status:
logger.info(f"[YOUTUBE] Upload progress: {int(status.progress() * 100)}%")
video_id = response.get("id")
logger.info(f"[YOUTUBE] Video uploaded successfully! ID: {video_id} -> https://youtu.be/{video_id}")
return video_id
def set_thumbnail(self, video_id: str, thumbnail_path: str) -> bool:
"""Upload custom thumbnail for a video."""
from googleapiclient.http import MediaFileUpload
if not os.path.exists(thumbnail_path):
logger.warning(f"[YOUTUBE] Thumbnail file not found: {thumbnail_path}")
return False
try:
service = self.get_service()
logger.info(f"[YOUTUBE] Setting thumbnail for video {video_id}...")
media = MediaFileUpload(thumbnail_path, mimetype="image/png")
service.thumbnails().set(videoId=video_id, media_body=media).execute()
logger.info(f"[YOUTUBE] Thumbnail set successfully for {video_id}")
return True
except Exception as e:
logger.error(f"[YOUTUBE] Failed to set thumbnail: {e}")
return False
def upload_caption(self, video_id: str, srt_path: str, lang: str = "en", name: str = "English") -> bool:
"""Upload subtitle/caption track for a video."""
from googleapiclient.http import MediaFileUpload
if not os.path.exists(srt_path):
return False
try:
service = self.get_service()
logger.info(f"[YOUTUBE] Uploading {lang} captions for video {video_id}...")
body = {
"snippet": {
"videoId": video_id,
"language": lang,
"name": name,
"isDraft": False
}
}
media = MediaFileUpload(srt_path, mimetype="application/x-subrip")
service.captions().insert(part="snippet", body=body, media_body=media).execute()
logger.info(f"[YOUTUBE] {lang} captions uploaded successfully.")
return True
except Exception as e:
logger.error(f"[YOUTUBE] Failed to upload captions: {e}")
return False
def upload_bundle(self, bundle_meta_path: str, lang: str = "en", privacy_status: Optional[str] = None) -> Dict[str, Any]:
"""Upload a complete packaged video bundle (video + metadata + thumbnail + captions)."""
with open(bundle_meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
video_path = meta.get("files", {}).get(lang)
if not video_path or not os.path.exists(video_path):
raise FileNotFoundError(f"Video file for language '{lang}' not found in bundle.")
title = meta.get("titles", ["Documentary"])[0] if lang == "en" else meta.get("titles_es", meta.get("titles", ["Documentary"]))[0]
desc = meta.get("description", "") if lang == "en" else meta.get("description_es", meta.get("description", ""))
tags = meta.get("tags", [])
# Select best thumbnail
thumb_list = meta.get("thumbnails", {}).get(lang, []) or meta.get("thumbnails_raw", [])
thumb_path = thumb_list[0] if thumb_list else None
srt_path = meta.get("subtitles", {}).get(lang)
# 1. Upload Video
video_id = self.upload_video(video_path, title, desc, tags, privacy_status=privacy_status)
# 2. Upload Thumbnail
if thumb_path:
self.set_thumbnail(video_id, thumb_path)
# 3. Upload Captions
if srt_path:
caption_name = "English" if lang == "en" else "Spanish"
self.upload_caption(video_id, srt_path, lang=lang, name=caption_name)
return {
"video_id": video_id,
"url": f"https://youtu.be/{video_id}",
"title": title,
"language": lang,
"privacy": privacy_status or self.default_privacy
}