Files
AI-Video/api/routers/publish.py

428 lines
13 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.
"""
Publish API router for multi-platform video distribution.
Endpoints:
- POST /publish/export - Format conversion and export
- POST /publish/bilibili - Publish to Bilibili (TODO)
- POST /publish/youtube - Publish to YouTube (TODO)
- GET /publish/tasks/{id} - Get task status
"""
import uuid
from datetime import datetime
from fastapi import APIRouter, HTTPException, Path, BackgroundTasks
from loguru import logger
from api.schemas.publish import (
PublishRequest,
PublishResultSchema,
PublishTaskSchema,
PublishStatusEnum,
PlatformRequirementsSchema,
VideoMetadataSchema,
)
from pixelle_video.services.publishing import (
VideoMetadata,
PublishStatus,
PublishTask,
Platform,
)
from pixelle_video.services.publishing.export_publisher import ExportPublisher
router = APIRouter(prefix="/publish", tags=["Publish"])
# In-memory task storage (use Redis in production)
_publish_tasks: dict = {}
# Publisher instances
_export_publisher = ExportPublisher()
@router.post("/export", response_model=PublishResultSchema)
async def export_video(
request: PublishRequest,
background_tasks: BackgroundTasks
):
"""
Convert video to platform-optimized format and export.
Optimizes for:
- Portrait 9:16 aspect ratio (1080x1920)
- H.264 codec
- ≤128MB file size
For manual upload to Douyin/Kuaishou.
"""
# Create task
task_id = str(uuid.uuid4())[:8]
metadata = VideoMetadata(
title=request.metadata.title,
description=request.metadata.description,
tags=request.metadata.tags,
category=request.metadata.category,
cover_path=request.metadata.cover_path,
privacy=request.metadata.privacy,
platform_options=request.metadata.platform_options,
)
task = PublishTask(
id=task_id,
video_path=request.video_path,
platform=Platform.EXPORT,
metadata=metadata,
status=PublishStatus.PENDING,
)
_publish_tasks[task_id] = task
logger.info(f"📤 Starting export task {task_id} for: {metadata.title}")
# Execute synchronously for now (can be moved to background)
result = await _export_publisher.publish(
request.video_path,
metadata,
progress_callback=lambda p, m: logger.info(f"Export {task_id}: {p:.0%} - {m}")
)
# Update task
task.status = PublishStatus(result.status.value)
task.result = result
task.updated_at = datetime.now()
return PublishResultSchema(
success=result.success,
platform="export",
status=PublishStatusEnum(result.status.value),
export_path=result.export_path,
error_message=result.error_message,
)
@router.get("/tasks/{task_id}", response_model=PublishTaskSchema)
async def get_publish_task(task_id: str = Path(..., description="Task ID")):
"""Get publishing task status"""
if task_id not in _publish_tasks:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
task = _publish_tasks[task_id]
return PublishTaskSchema(
id=task.id,
platform=task.platform.value,
status=PublishStatusEnum(task.status.value),
result=PublishResultSchema(
success=task.result.success,
platform=task.result.platform.value,
status=PublishStatusEnum(task.result.status.value),
export_path=task.result.export_path,
error_message=task.result.error_message,
) if task.result else None,
created_at=task.created_at.isoformat(),
updated_at=task.updated_at.isoformat() if task.updated_at else None,
)
@router.get("/requirements/{platform}", response_model=PlatformRequirementsSchema)
async def get_platform_requirements(platform: str = Path(..., description="Platform name")):
"""Get platform-specific requirements"""
requirements = {
"export": {
"max_file_size_mb": 128,
"max_duration_seconds": 900,
"supported_formats": ["mp4"],
"recommended_resolution": (1080, 1920),
"recommended_codec": "h264",
},
"bilibili": {
"max_file_size_mb": 4096,
"max_duration_seconds": 14400, # 4 hours
"supported_formats": ["mp4", "flv", "webm"],
"recommended_resolution": (1920, 1080), # Landscape
"recommended_codec": "h264",
},
"youtube": {
"max_file_size_mb": 256000, # 256GB
"max_duration_seconds": 43200, # 12 hours
"supported_formats": ["mp4", "mov", "avi", "webm"],
"recommended_resolution": (1920, 1080),
"recommended_codec": "h264",
},
}
if platform not in requirements:
raise HTTPException(status_code=404, detail=f"Platform {platform} not supported")
return requirements[platform]
from pixelle_video.services.publishing.bilibili_publisher import BilibiliPublisher
# Bilibili publisher instance
_bilibili_publisher = BilibiliPublisher()
@router.post("/bilibili", response_model=PublishResultSchema)
async def publish_to_bilibili(request: PublishRequest):
"""
Publish video to Bilibili.
Requires environment variables:
- BILIBILI_ACCESS_TOKEN or
- BILIBILI_SESSDATA + BILIBILI_BILI_JCT
"""
if not await _bilibili_publisher.validate_credentials():
raise HTTPException(
status_code=400,
detail="B站凭证未配置。请设置 BILIBILI_ACCESS_TOKEN 或 BILIBILI_SESSDATA 环境变量"
)
metadata = VideoMetadata(
title=request.metadata.title,
description=request.metadata.description,
tags=request.metadata.tags,
category=request.metadata.category,
cover_path=request.metadata.cover_path,
privacy=request.metadata.privacy,
platform_options=request.metadata.platform_options,
)
logger.info(f"📤 Starting Bilibili upload: {metadata.title}")
result = await _bilibili_publisher.publish(
request.video_path,
metadata,
progress_callback=lambda p, m: logger.info(f"Bilibili: {p:.0%} - {m}")
)
return PublishResultSchema(
success=result.success,
platform="bilibili",
status=PublishStatusEnum(result.status.value),
video_url=result.video_url,
platform_video_id=result.platform_video_id,
error_message=result.error_message,
)
from pixelle_video.services.publishing.youtube_publisher import YouTubePublisher
# YouTube publisher instance
_youtube_publisher = YouTubePublisher()
@router.post("/youtube", response_model=PublishResultSchema)
async def publish_to_youtube(request: PublishRequest):
"""
Publish video to YouTube.
Requires:
- config/youtube_client_secrets.json (OAuth 2.0 credentials)
- First-time auth will open browser for authorization
"""
if not await _youtube_publisher.validate_credentials():
raise HTTPException(
status_code=400,
detail="YouTube 凭证未配置。请添加 config/youtube_client_secrets.json"
)
metadata = VideoMetadata(
title=request.metadata.title,
description=request.metadata.description,
tags=request.metadata.tags,
category=request.metadata.category,
cover_path=request.metadata.cover_path,
privacy=request.metadata.privacy,
platform_options=request.metadata.platform_options,
)
logger.info(f"📤 Starting YouTube upload: {metadata.title}")
result = await _youtube_publisher.publish(
request.video_path,
metadata,
progress_callback=lambda p, m: logger.info(f"YouTube: {p:.0%} - {m}")
)
return PublishResultSchema(
success=result.success,
platform="youtube",
status=PublishStatusEnum(result.status.value),
video_url=result.video_url,
platform_video_id=result.platform_video_id,
error_message=result.error_message,
)
# ============================================================
# Async Task Queue Endpoints
# ============================================================
from pixelle_video.services.publishing.task_manager import get_publish_manager, TaskPriority
from pydantic import BaseModel
from typing import List
class AsyncPublishRequest(BaseModel):
"""Request for async publishing"""
video_path: str
platform: str # export, bilibili, youtube
metadata: VideoMetadataSchema
priority: str = "normal" # low, normal, high
class QueuedTaskSchema(BaseModel):
"""Schema for queued task"""
id: str
platform: str
status: str
progress: float
progress_message: str
retries: int
created_at: str
started_at: str = None
completed_at: str = None
class QueueStatusSchema(BaseModel):
"""Queue status overview"""
pending: int
active: int
completed: int
failed: int
workers: int
@router.post("/async", response_model=dict)
async def publish_async(request: AsyncPublishRequest):
"""
Submit a publish task to the background queue.
Returns immediately with task ID for tracking.
"""
manager = get_publish_manager()
# Ensure manager is running
if not manager._running:
await manager.start()
# Map platform string to enum
platform_map = {
"export": Platform.EXPORT,
"bilibili": Platform.BILIBILI,
"youtube": Platform.YOUTUBE,
}
platform = platform_map.get(request.platform.lower())
if not platform:
raise HTTPException(status_code=400, detail=f"Invalid platform: {request.platform}")
# Map priority
priority_map = {
"low": TaskPriority.LOW,
"normal": TaskPriority.NORMAL,
"high": TaskPriority.HIGH,
}
priority = priority_map.get(request.priority.lower(), TaskPriority.NORMAL)
metadata = VideoMetadata(
title=request.metadata.title,
description=request.metadata.description,
tags=request.metadata.tags,
category=request.metadata.category,
cover_path=request.metadata.cover_path,
privacy=request.metadata.privacy,
platform_options=request.metadata.platform_options,
)
task_id = await manager.enqueue(
video_path=request.video_path,
platform=platform,
metadata=metadata,
priority=priority,
)
return {
"task_id": task_id,
"status": "queued",
"message": f"Task queued for {request.platform}",
}
@router.get("/queue/status", response_model=QueueStatusSchema)
async def get_queue_status():
"""Get queue status overview."""
manager = get_publish_manager()
all_tasks = manager.get_all_tasks()
pending = sum(1 for t in all_tasks if t.task.status == PublishStatus.PENDING)
active = len(manager.get_active_tasks())
completed = sum(1 for t in all_tasks if t.task.status == PublishStatus.PUBLISHED)
failed = sum(1 for t in all_tasks if t.task.status == PublishStatus.FAILED)
return QueueStatusSchema(
pending=pending,
active=active,
completed=completed,
failed=failed,
workers=manager.max_workers,
)
@router.get("/queue/tasks", response_model=List[QueuedTaskSchema])
async def list_queued_tasks():
"""List all tasks in queue."""
manager = get_publish_manager()
result = []
for qt in manager.get_all_tasks():
result.append(QueuedTaskSchema(
id=qt.task.id,
platform=qt.task.platform.value,
status=qt.task.status.value,
progress=qt.progress,
progress_message=qt.progress_message,
retries=qt.retries,
created_at=qt.created_at.isoformat(),
started_at=qt.started_at.isoformat() if qt.started_at else None,
completed_at=qt.completed_at.isoformat() if qt.completed_at else None,
))
return result
@router.get("/queue/tasks/{task_id}", response_model=QueuedTaskSchema)
async def get_queued_task(task_id: str = Path(..., description="Task ID")):
"""Get specific queued task status."""
manager = get_publish_manager()
qt = manager.get_task(task_id)
if not qt:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return QueuedTaskSchema(
id=qt.task.id,
platform=qt.task.platform.value,
status=qt.task.status.value,
progress=qt.progress,
progress_message=qt.progress_message,
retries=qt.retries,
created_at=qt.created_at.isoformat(),
started_at=qt.started_at.isoformat() if qt.started_at else None,
completed_at=qt.completed_at.isoformat() if qt.completed_at else None,
)