# 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. """ Bilibili Publisher - Upload videos to Bilibili using their Open Platform API. Flow: 1. Get preupload info (upos_uri, auth, chunk_size) 2. Upload video chunks (8MB each) 3. Merge chunks 4. Submit video with metadata """ import os import math import aiohttp import asyncio 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, ) # Bilibili API endpoints BILIBILI_PREUPLOAD_URL = "https://member.bilibili.com/preupload" BILIBILI_SUBMIT_URL = "https://member.bilibili.com/x/vu/web/add" # Chunk size: 8MB (recommended by Bilibili) CHUNK_SIZE = 8 * 1024 * 1024 class BilibiliPublisher(Publisher): """ Publisher for Bilibili video platform. Requires: - access_token: OAuth access token - refresh_token: For token refresh (optional) """ platform = Platform.BILIBILI def __init__( self, access_token: Optional[str] = None, refresh_token: Optional[str] = None, sessdata: Optional[str] = None, # Alternative: use cookies bili_jct: Optional[str] = None, ): self.access_token = access_token or os.getenv("BILIBILI_ACCESS_TOKEN") self.refresh_token = refresh_token or os.getenv("BILIBILI_REFRESH_TOKEN") self.sessdata = sessdata or os.getenv("BILIBILI_SESSDATA") self.bili_jct = bili_jct or os.getenv("BILIBILI_BILI_JCT") # Upload state self._upload_id = None self._upos_uri = None self._auth = None self._endpoint = None async def publish( self, video_path: str, metadata: VideoMetadata, progress_callback: Optional[callable] = None ) -> PublishResult: """Upload and publish video to Bilibili.""" started_at = datetime.now() try: if not await self.validate_credentials(): return PublishResult( success=False, platform=Platform.BILIBILI, status=PublishStatus.FAILED, error_message="B站凭证未配置或已过期", started_at=started_at, completed_at=datetime.now(), ) video_file = Path(video_path) if not video_file.exists(): return PublishResult( success=False, platform=Platform.BILIBILI, status=PublishStatus.FAILED, error_message=f"视频文件不存在: {video_path}", started_at=started_at, completed_at=datetime.now(), ) file_size = video_file.stat().st_size if progress_callback: progress_callback(0.05, "获取上传信息...") # Step 1: Get preupload info preupload_info = await self._preupload(video_file.name, file_size) if not preupload_info: return PublishResult( success=False, platform=Platform.BILIBILI, status=PublishStatus.FAILED, error_message="获取上传信息失败", started_at=started_at, completed_at=datetime.now(), ) if progress_callback: progress_callback(0.1, "上传视频分片...") # Step 2: Upload chunks chunk_count = math.ceil(file_size / CHUNK_SIZE) uploaded_chunks = 0 async with aiohttp.ClientSession() as session: with open(video_path, "rb") as f: for chunk_index in range(chunk_count): chunk_data = f.read(CHUNK_SIZE) chunk_start = chunk_index * CHUNK_SIZE chunk_end = min(chunk_start + len(chunk_data), file_size) success = await self._upload_chunk( session, chunk_data, chunk_index, chunk_count, chunk_start, chunk_end, file_size, ) if not success: return PublishResult( success=False, platform=Platform.BILIBILI, status=PublishStatus.FAILED, error_message=f"分片 {chunk_index + 1}/{chunk_count} 上传失败", started_at=started_at, completed_at=datetime.now(), ) uploaded_chunks += 1 progress = 0.1 + (0.7 * uploaded_chunks / chunk_count) if progress_callback: progress_callback(progress, f"上传分片 {uploaded_chunks}/{chunk_count}") if progress_callback: progress_callback(0.85, "合并视频...") # Step 3: Merge chunks video_filename = await self._merge_chunks(chunk_count, file_size) if not video_filename: return PublishResult( success=False, platform=Platform.BILIBILI, status=PublishStatus.FAILED, error_message="视频合并失败", started_at=started_at, completed_at=datetime.now(), ) if progress_callback: progress_callback(0.9, "提交稿件...") # Step 4: Submit video bvid = await self._submit_video(video_filename, metadata) if not bvid: return PublishResult( success=False, platform=Platform.BILIBILI, 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.BILIBILI, status=PublishStatus.PUBLISHED, video_url=f"https://www.bilibili.com/video/{bvid}", platform_video_id=bvid, started_at=started_at, completed_at=datetime.now(), ) except Exception as e: logger.error(f"Bilibili publish failed: {e}") return PublishResult( success=False, platform=Platform.BILIBILI, status=PublishStatus.FAILED, error_message=str(e), started_at=started_at, completed_at=datetime.now(), ) async def _preupload(self, filename: str, file_size: int) -> Optional[Dict]: """Get preupload info from Bilibili.""" params = { "name": filename, "size": file_size, "r": "upos", "profile": "ugcupos/bup", } headers = self._get_headers() try: async with aiohttp.ClientSession() as session: async with session.get( BILIBILI_PREUPLOAD_URL, params=params, headers=headers ) as resp: if resp.status != 200: logger.error(f"Preupload failed: {resp.status}") return None data = await resp.json() self._upos_uri = data.get("upos_uri") self._auth = data.get("auth") self._endpoint = data.get("endpoint") self._upload_id = data.get("upload_id") logger.info(f"Preupload success: {self._upos_uri}") return data except Exception as e: logger.error(f"Preupload error: {e}") return None async def _upload_chunk( self, session: aiohttp.ClientSession, chunk_data: bytes, chunk_index: int, chunk_count: int, chunk_start: int, chunk_end: int, total_size: int, ) -> bool: """Upload a single chunk.""" if not self._upos_uri or not self._auth: return False # Build upload URL upload_url = f"https:{self._endpoint}{self._upos_uri}" params = { "uploadId": self._upload_id, "partNumber": chunk_index + 1, "chunk": chunk_index, "chunks": chunk_count, "size": len(chunk_data), "start": chunk_start, "end": chunk_end, "total": total_size, } headers = { "X-Upos-Auth": self._auth, "Content-Type": "application/octet-stream", } try: async with session.put( upload_url, params=params, headers=headers, data=chunk_data ) as resp: if resp.status not in [200, 201, 204]: logger.error(f"Chunk upload failed: {resp.status}") return False return True except Exception as e: logger.error(f"Chunk upload error: {e}") return False async def _merge_chunks(self, chunk_count: int, file_size: int) -> Optional[str]: """Merge uploaded chunks.""" if not self._upos_uri: return None merge_url = f"https:{self._endpoint}{self._upos_uri}" params = { "output": "json", "name": self._upos_uri.split("/")[-1], "profile": "ugcupos/bup", "uploadId": self._upload_id, "biz_id": "", } # Build parts list parts = [{"partNumber": i + 1, "eTag": "etag"} for i in range(chunk_count)] headers = { "X-Upos-Auth": self._auth, "Content-Type": "application/json", } try: async with aiohttp.ClientSession() as session: async with session.post( merge_url, params=params, headers=headers, json={"parts": parts} ) as resp: if resp.status != 200: logger.error(f"Merge failed: {resp.status}") return None data = await resp.json() return self._upos_uri.split("/")[-1].split(".")[0] except Exception as e: logger.error(f"Merge error: {e}") return None async def _submit_video( self, video_filename: str, metadata: VideoMetadata ) -> Optional[str]: """Submit video with metadata.""" # Default to "生活" category (tid=160) tid = metadata.platform_options.get("tid", 160) data = { "copyright": 1, # 1=原创 "videos": [{ "filename": video_filename, "title": metadata.title, "desc": metadata.description, }], "title": metadata.title, "desc": metadata.description, "tid": tid, "tag": ",".join(metadata.tags) if metadata.tags else "", "source": "", "cover": metadata.cover_path or "", "no_reprint": 1, "open_elec": 0, } headers = self._get_headers() headers["Content-Type"] = "application/json" try: async with aiohttp.ClientSession() as session: async with session.post( BILIBILI_SUBMIT_URL, headers=headers, json=data ) as resp: result = await resp.json() if result.get("code") == 0: bvid = result.get("data", {}).get("bvid") logger.info(f"Video submitted: {bvid}") return bvid else: logger.error(f"Submit failed: {result}") return None except Exception as e: logger.error(f"Submit error: {e}") return None def _get_headers(self) -> Dict[str, str]: """Get common headers with authentication.""" headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "Referer": "https://www.bilibili.com/", } if self.access_token: headers["Authorization"] = f"Bearer {self.access_token}" if self.sessdata: headers["Cookie"] = f"SESSDATA={self.sessdata}" if self.bili_jct: headers["Cookie"] += f"; bili_jct={self.bili_jct}" return headers async def validate_credentials(self) -> bool: """Check if Bilibili credentials are configured.""" return bool(self.access_token or self.sessdata) def get_platform_requirements(self) -> Dict[str, Any]: return { "max_file_size_mb": 4096, # 4GB "max_duration_seconds": 14400, # 4 hours "supported_formats": ["mp4", "flv", "webm", "mov"], "recommended_resolution": (1920, 1080), "recommended_codec": "h264", "chunk_size_mb": 8, }