# Copyright (C) 2025 AIDC-AI # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # http://www.apache.org/licenses/LICENSE-2.0 # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ YouTube Publisher - Upload videos to YouTube using Data API v3. Requires: - Google Cloud project with YouTube Data API v3 enabled - OAuth 2.0 credentials (client_secrets.json) """ import os import pickle from pathlib import Path from datetime import datetime from typing import Optional, Dict, Any from loguru import logger from pixelle_video.services.publishing import ( Publisher, Platform, PublishStatus, VideoMetadata, PublishResult, ) # YouTube category IDs YOUTUBE_CATEGORIES = { "film": "1", "autos": "2", "music": "10", "pets": "15", "sports": "17", "travel": "19", "gaming": "20", "people": "22", "comedy": "23", "entertainment": "24", "news": "25", "howto": "26", "education": "27", "science": "28", "nonprofits": "29", } class YouTubePublisher(Publisher): """ Publisher for YouTube video platform. Uses Google API Python Client for uploading videos. Setup: 1. Create project in Google Cloud Console 2. Enable YouTube Data API v3 3. Create OAuth 2.0 credentials 4. Download client_secrets.json """ platform = Platform.YOUTUBE def __init__( self, client_secrets_path: Optional[str] = None, token_path: Optional[str] = None, ): self.client_secrets_path = client_secrets_path or os.getenv( "YOUTUBE_CLIENT_SECRETS", "./config/youtube_client_secrets.json" ) self.token_path = token_path or os.getenv( "YOUTUBE_TOKEN_PATH", "./config/youtube_token.pickle" ) self._youtube_service = None async def publish( self, video_path: str, metadata: VideoMetadata, progress_callback: Optional[callable] = None ) -> PublishResult: """Upload and publish video to YouTube.""" started_at = datetime.now() try: if not await self.validate_credentials(): return PublishResult( success=False, platform=Platform.YOUTUBE, status=PublishStatus.FAILED, error_message="YouTube 凭证未配置。请配置 client_secrets.json", started_at=started_at, completed_at=datetime.now(), ) video_file = Path(video_path) if not video_file.exists(): return PublishResult( success=False, platform=Platform.YOUTUBE, status=PublishStatus.FAILED, error_message=f"视频文件不存在: {video_path}", started_at=started_at, completed_at=datetime.now(), ) if progress_callback: progress_callback(0.1, "初始化 YouTube API...") # Initialize YouTube service youtube = await self._get_youtube_service() if not youtube: return PublishResult( success=False, platform=Platform.YOUTUBE, status=PublishStatus.FAILED, error_message="无法初始化 YouTube API 服务", started_at=started_at, completed_at=datetime.now(), ) if progress_callback: progress_callback(0.2, "准备上传...") # Prepare video metadata category_id = self._get_category_id(metadata.category) privacy_status = self._map_privacy(metadata.privacy) body = { "snippet": { "title": metadata.title, "description": metadata.description, "tags": metadata.tags, "categoryId": category_id, }, "status": { "privacyStatus": privacy_status, "selfDeclaredMadeForKids": False, } } # Check for synthetic media flag if metadata.platform_options.get("contains_synthetic_media"): body["status"]["containsSyntheticMedia"] = True if progress_callback: progress_callback(0.3, "上传视频...") # Upload using resumable upload video_id = await self._upload_video(youtube, video_path, body, progress_callback) if not video_id: return PublishResult( success=False, platform=Platform.YOUTUBE, status=PublishStatus.FAILED, error_message="视频上传失败", started_at=started_at, completed_at=datetime.now(), ) if progress_callback: progress_callback(1.0, "发布成功") return PublishResult( success=True, platform=Platform.YOUTUBE, status=PublishStatus.PUBLISHED, video_url=f"https://www.youtube.com/watch?v={video_id}", platform_video_id=video_id, started_at=started_at, completed_at=datetime.now(), ) except Exception as e: logger.error(f"YouTube publish failed: {e}") return PublishResult( success=False, platform=Platform.YOUTUBE, status=PublishStatus.FAILED, error_message=str(e), started_at=started_at, completed_at=datetime.now(), ) async def _get_youtube_service(self): """Get authenticated YouTube service.""" try: from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build SCOPES = ["https://www.googleapis.com/auth/youtube.upload"] creds = None # Load saved token if os.path.exists(self.token_path): with open(self.token_path, "rb") as token: creds = pickle.load(token) # Refresh or get new credentials if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: from google.auth.transport.requests import Request creds.refresh(Request()) else: if not os.path.exists(self.client_secrets_path): logger.error(f"Client secrets not found: {self.client_secrets_path}") return None flow = InstalledAppFlow.from_client_secrets_file( self.client_secrets_path, SCOPES ) creds = flow.run_local_server(port=0) # Save token with open(self.token_path, "wb") as token: pickle.dump(creds, token) return build("youtube", "v3", credentials=creds) except ImportError: logger.error("Google API libraries not installed. Run: pip install google-api-python-client google-auth-oauthlib") return None except Exception as e: logger.error(f"Failed to initialize YouTube service: {e}") return None async def _upload_video( self, youtube, video_path: str, body: dict, progress_callback: Optional[callable] = None ) -> Optional[str]: """Upload video using resumable upload.""" try: from googleapiclient.http import MediaFileUpload media = MediaFileUpload( video_path, chunksize=1024 * 1024, # 1MB chunks resumable=True ) request = youtube.videos().insert( part=",".join(body.keys()), body=body, media_body=media ) response = None while response is None: status, response = request.next_chunk() if status: progress = 0.3 + (0.6 * status.progress()) if progress_callback: progress_callback(progress, f"上传 {int(status.progress() * 100)}%") video_id = response.get("id") logger.info(f"Video uploaded: {video_id}") return video_id except Exception as e: logger.error(f"Upload failed: {e}") return None def _get_category_id(self, category: Optional[str]) -> str: """Map category name to YouTube category ID.""" if not category: return "22" # Default: People & Blogs return YOUTUBE_CATEGORIES.get(category.lower(), "22") def _map_privacy(self, privacy: str) -> str: """Map privacy setting to YouTube format.""" mapping = { "public": "public", "private": "private", "unlisted": "unlisted", } return mapping.get(privacy, "private") async def validate_credentials(self) -> bool: """Check if YouTube credentials are configured.""" return os.path.exists(self.client_secrets_path) or os.path.exists(self.token_path) def get_platform_requirements(self) -> Dict[str, Any]: return { "max_file_size_mb": 256000, # 256GB "max_duration_seconds": 43200, # 12 hours "supported_formats": ["mp4", "mov", "avi", "webm", "mkv"], "recommended_resolution": (1920, 1080), "recommended_codec": "h264", "quota_cost_per_upload": 100, }