Files
AI-Video/pixelle_video/services/publishing/bilibili_publisher.py

427 lines
15 KiB
Python

# 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,
}