audiobooks-backend / app /api /convert.py
GitHub Action
Deploy from GitHub Actions - Fri Jun 20 07:41:03 UTC 2025
ac251c6
Raw
History Blame Contribute Delete
14.7 kB
"""
音频转换API路由
处理EPUB转音频的核心业务逻辑
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, field_validator
from typing import List
from datetime import datetime
import re
from app.models.task import (
ConversionTask,
VoiceSettings as TaskVoiceSettings,
TaskStatus,
)
from app.services.task_queue import task_queue
from app.core.logging import get_logger
logger = get_logger(__name__)
router = APIRouter()
class VoiceSettings(BaseModel):
"""语音设置模型"""
language: str = "zh-CN"
voice_name: str = "xiaoxiao"
speed: float = 1.0
pitch: int = 0
volume: float = 0.8
@field_validator("speed")
@classmethod
def validate_speed(cls, v):
if not 0.5 <= v <= 2.0:
raise ValueError("语速必须在0.5-2.0之间")
return v
@field_validator("pitch")
@classmethod
def validate_pitch(cls, v):
if not -20 <= v <= 20:
raise ValueError("音调必须在-20到20之间")
return v
class ConvertRequest(BaseModel):
"""转换请求模型"""
file_url: str
voice_settings: VoiceSettings = VoiceSettings()
output_formats: List[str] = ["mp3"]
audio_quality: str = "high"
split_by_chapter: bool = True
max_segment_length: int = 300
@field_validator("file_url")
@classmethod
def validate_file_url(cls, v):
if not v or not v.startswith(("http://", "https://")):
raise ValueError("文件URL格式无效")
if not v.lower().endswith((".epub", ".txt")):
raise ValueError("仅支持EPUB和TXT格式文件")
return v
@field_validator("output_formats")
@classmethod
def validate_output_formats(cls, v):
supported_formats = ["mp3", "wav", "m4a"]
for fmt in v:
if fmt not in supported_formats:
raise ValueError(f"不支持的输出格式: {fmt}")
return v
class ConvertR2Request(BaseModel):
"""R2路径转换请求模型"""
r2_file_path: str
voice_settings: VoiceSettings = VoiceSettings()
output_formats: List[str] = ["mp3"]
audio_quality: str = "high"
split_by_chapter: bool = True
max_segment_length: int = 300
@field_validator("r2_file_path")
@classmethod
def validate_r2_file_path(cls, v):
if not v or not v.strip():
raise ValueError("R2文件路径不能为空")
# 清理路径
v = v.strip()
# 验证路径格式:users/{user_id}/{category}/{task_id}/{filename}
path_parts = v.split('/')
if len(path_parts) < 4:
raise ValueError("R2文件路径格式无效,应为: users/{user_id}/{category}/{task_id}/{filename}")
if path_parts[0] != "users":
raise ValueError("R2文件路径必须以'users/'开头")
# 验证各部分不能为空
for i, part in enumerate(path_parts):
if not part.strip():
raise ValueError(f"路径第{i+1}部分不能为空")
# 验证文件扩展名
filename = path_parts[-1]
if not filename.lower().endswith((".epub", ".txt")):
raise ValueError("仅支持EPUB和TXT格式文件")
return v
@field_validator("output_formats")
@classmethod
def validate_output_formats(cls, v):
supported_formats = ["mp3", "wav", "m4a"]
for fmt in v:
if fmt not in supported_formats:
raise ValueError(f"不支持的输出格式: {fmt}")
return v
class ConvertResponse(BaseModel):
"""转换响应模型"""
task_id: str
status: str
message: str
queue_position: int
estimated_time: str
queue_size: int
@router.post("/convert", response_model=ConvertResponse)
async def submit_conversion_task(request: ConvertRequest):
"""提交EPUB转音频任务"""
try:
logger.info(f"收到转换请求: {request.file_url}")
# 创建转换任务
task = ConversionTask(
file_url=request.file_url,
voice_settings=TaskVoiceSettings(
language=request.voice_settings.language,
voice_name=request.voice_settings.voice_name,
speed=request.voice_settings.speed,
pitch=request.voice_settings.pitch,
volume=request.voice_settings.volume,
),
output_formats=request.output_formats,
audio_quality=request.audio_quality,
split_by_chapter=request.split_by_chapter,
max_segment_length=request.max_segment_length,
)
# 提取原始文件名
import os
task.original_filename = os.path.basename(request.file_url)
# 添加到任务队列
success = task_queue.add_task(task)
if not success:
raise HTTPException(
status_code=503,
detail={
"error": {
"code": "QUEUE_FULL",
"message": "任务队列已满,请稍后重试",
"timestamp": datetime.now().isoformat(),
}
},
)
# 获取队列状态
queue_status = task_queue.get_queue_status()
return ConvertResponse(
task_id=task.task_id,
status=task.status.value,
message="音频转换任务已添加到队列",
queue_position=task.queue_position,
estimated_time="15分钟", # TODO: 基于队列状态计算
queue_size=queue_status["queue_size"],
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "INVALID_REQUEST",
"message": str(e),
"timestamp": datetime.now().isoformat(),
}
},
)
except Exception as e:
logger.error(f"提交转换任务失败: {str(e)}")
raise HTTPException(
status_code=500,
detail={
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "服务器内部错误",
"timestamp": datetime.now().isoformat(),
}
},
)
@router.post("/convert-r2", response_model=ConvertResponse)
async def submit_conversion_task_r2(request: ConvertR2Request):
"""提交R2路径EPUB转音频任务"""
try:
logger.info(f"收到R2路径转换请求: {request.r2_file_path}")
# 对于R2路径,直接使用r2://前缀,让存储服务处理下载逻辑
# 这样可以避免构建可能不正确的HTTP URL
file_url = f"r2://{request.r2_file_path}"
# 创建转换任务
task = ConversionTask(
file_url=file_url,
voice_settings=TaskVoiceSettings(
language=request.voice_settings.language,
voice_name=request.voice_settings.voice_name,
speed=request.voice_settings.speed,
pitch=request.voice_settings.pitch,
volume=request.voice_settings.volume,
),
output_formats=request.output_formats,
audio_quality=request.audio_quality,
split_by_chapter=request.split_by_chapter,
max_segment_length=request.max_segment_length,
)
# 设置R2路径信息
task.r2_file_path = request.r2_file_path
# 提取原始文件名
import os
task.original_filename = os.path.basename(request.r2_file_path)
# 添加到任务队列
success = task_queue.add_task(task)
if not success:
raise HTTPException(
status_code=503,
detail={
"error": {
"code": "QUEUE_FULL",
"message": "任务队列已满,请稍后重试",
"timestamp": datetime.now().isoformat(),
}
},
)
# 获取队列状态
queue_status = task_queue.get_queue_status()
return ConvertResponse(
task_id=task.task_id,
status=task.status.value,
message="R2路径音频转换任务已添加到队列",
queue_position=task.queue_position,
estimated_time="15分钟", # TODO: 基于队列状态计算
queue_size=queue_status["queue_size"],
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "INVALID_REQUEST",
"message": str(e),
"timestamp": datetime.now().isoformat(),
}
},
)
except Exception as e:
logger.error(f"提交R2路径转换任务失败: {str(e)}")
raise HTTPException(
status_code=500,
detail={
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "服务器内部错误",
"timestamp": datetime.now().isoformat(),
}
},
)
@router.get("/convert/{task_id}/status")
async def get_conversion_status(task_id: str):
"""查询转换任务状态(兼容旧版本)"""
try:
# 验证任务ID格式
if not re.match(r"^audio_[0-9a-f-]+$", task_id):
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "INVALID_TASK_ID",
"message": "任务ID格式无效",
"timestamp": datetime.now().isoformat(),
}
},
)
# 获取任务信息
task = task_queue.get_task(task_id)
if not task:
raise HTTPException(
status_code=404,
detail={
"error": {
"code": "TASK_NOT_FOUND",
"message": "任务不存在",
"timestamp": datetime.now().isoformat(),
}
},
)
return task.to_dict()
except HTTPException:
raise
except Exception as e:
logger.error(f"查询任务状态失败: {str(e)}")
raise HTTPException(
status_code=500,
detail={
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "服务器内部错误",
"timestamp": datetime.now().isoformat(),
}
},
)
@router.get("/convert/{user_id}/{task_id}/status")
async def get_conversion_status_with_user(user_id: str, task_id: str):
"""查询转换任务状态(新版本,支持从R2读取)"""
try:
# 验证参数格式
if not user_id or not task_id:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "INVALID_PARAMETERS",
"message": "用户ID和任务ID不能为空",
"timestamp": datetime.now().isoformat(),
}
},
)
# 获取任务信息(优先从R2读取)
task = await task_queue.get_task_with_user_id(user_id, task_id)
if not task:
raise HTTPException(
status_code=404,
detail={
"error": {
"code": "TASK_NOT_FOUND",
"message": "任务不存在",
"timestamp": datetime.now().isoformat(),
}
},
)
return task.to_dict()
except HTTPException:
raise
except Exception as e:
logger.error(f"查询任务状态失败: {str(e)}")
raise HTTPException(
status_code=500,
detail={
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "服务器内部错误",
"timestamp": datetime.now().isoformat(),
}
},
)
@router.get("/convert/{task_id}/result")
async def get_conversion_result(task_id: str):
"""获取转换结果"""
try:
# 验证任务ID格式
if not re.match(r"^audio_[0-9a-f-]+$", task_id):
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "INVALID_TASK_ID",
"message": "任务ID格式无效",
"timestamp": datetime.now().isoformat(),
}
},
)
# 获取任务信息
task = task_queue.get_task(task_id)
if not task:
raise HTTPException(
status_code=404,
detail={
"error": {
"code": "TASK_NOT_FOUND",
"message": "任务不存在",
"timestamp": datetime.now().isoformat(),
}
},
)
# 检查任务状态
if task.status != TaskStatus.COMPLETED:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "TASK_NOT_COMPLETED",
"message": f"任务尚未完成,当前状态: {task.status.value}",
"timestamp": datetime.now().isoformat(),
}
},
)
# 返回完整的任务结果
result = task.to_dict()
result.update(
{
"original_filename": task.original_filename,
"audio_files": task.audio_files,
"metadata": task.metadata,
}
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"获取转换结果失败: {str(e)}")
raise HTTPException(
status_code=500,
detail={
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "服务器内部错误",
"timestamp": datetime.now().isoformat(),
}
},
)